# Nuri Wallet Connector API and MCP Documentation
Last updated: 2026-07-24
This is the canonical operator and agent documentation for the public Nuri Wallet Connector at
`https://connect.nuri.com`.
> Release status: `0.1.17` is pushed and live on `connect.nuri.com`.
The connector has two interfaces backed by the same Cloudflare Worker:
1. REST endpoints for simple HTTP callers.
2. MCP JSON-RPC over HTTPS at `/mcp` for agents.
The repository also ships an agent integration skill at
`.claude/skills/nuri-connect/SKILL.md`. It routes agents to live MCP discovery and must not be confused with the
historical pre-build design archive in `docs/history/2026-07-prf-signer-design-log.md`.
The MCP transport is intentionally open and requires no transport auth. Actions that need wallet key material
complete through the bridge browser, where the user gives the passkey PRF signature.
## Current Status
| Area | Status |
| --- | --- |
| Public base URL | `https://connect.nuri.com` |
| Public wallet entry | `GET /`: centered `Create Passkey Wallet` left and `Access Passkey Wallet` right |
| Access-only entry | `GET /access`: only `Access Passkey Wallet` |
| Create form | `GET /create`: centered; international phone with `+`; inline `switch to email`; identity field hides when Create starts |
| REST health | live: `GET /healthz` |
| REST manifest | live: `GET /capabilities` |
| MCP documentation | live: `GET /mcp` |
| MCP transport | live: `POST /mcp` on the same URL |
| MCP auth | none |
| Agent skill | `.claude/skills/nuri-connect/SKILL.md`, synced to the live tool contract |
| Wallet Create session | live: `wallet_create_start` returns `/create/:session_id` |
| Typed Sign session | live: `wallet_sign_start` returns a 60-minute `/signature/:session_id`; `/sign/:session_id` remains an alias |
| Combined Monerium login | live: one EOA derivation plus all-chain SIWE signatures in one passkey prompt; completion waits for the fixed Monerium endpoint |
| Sign approval UI | live: mobile/tablet signs locally; desktop defaults to a local QR handoff with a small desktop-passkey fallback and polls the same session |
| Wallet link session | live: `wallet_link_start` returns a 30-minute approval URL |
| Wallet backup session | live with `PRIVY_APP_ID`: `wallet_backup_start` returns a 30-minute `/backup/:session_id` |
| Browser passkey pages | live at `/create/:session_id`, `/signature/:session_id`, `/sign/:session_id`, and `/approve/:session_id` |
| Wallet tuple returned to MCP | after user approval via `wallet_link_result` |
| Nuri Expo derivation | exact `nuri-expo-wallet-v1` PRF/HKDF/BIP86/EOA/Nostr contract |
| Credential public key | verified from registration; bound in Arkade and encrypted Nostr recovery |
| Assertion verification | new credentials: exact origin/RP/challenge/UP/UV/ES256/binding/counter verification; legacy Access: session-bound EOA proof; legacy Sign: exact typed wallet signature; both legacy paths emit a structured warning |
| Nostr recovery | required for Create completion; optional best-effort background recovery during Sign; not queried during Access |
| Phone account | international-number digits become `<username>@nuri.com` during Create |
| Email account | compatible local part before `@` becomes `<username>@nuri.com` during Create |
| Access result UI | only `APPROVED` and `show details`; seven public identities are collapsed by default |
| Access resilience | approval completes even if Nostr or a legacy credential public key is missing |
| Bitcoin / Arkade identities | Passkey BIP86, Passkey + Nuri MuSig2, Nuri Arkade Passkey + Nuri + Arkade (2 of 2 of 3), and a separate classic Arkade Passkey + Arkade (2 of 2) contract |
| Browser address display | seven signer-labelled identities with valid `bc1p...`, `ark1...`, checksummed EOA, `npub`, and onchain boarding `bc1p...` values |
| Completion callbacks | Create: credential-free wake-up GET; Sign: credential-free verified-result POST; polling remains the fallback |
| Frontend design system | exact Nuri package pinned to commit `0b251cdaeb975cc25663dd643f9c72acceae692e` |
| Arkade info | live upstream call to `arkade.nuri.com/v4` |
| Arkade receive invoice | live, requires wallet tuple |
| Arkade receive sync | live, requires wallet tuple |
| Arkade v5 send prepare/cosign/complete | live, requires wallet tuple and already-built direct send package |
| Lightning pay from invoice only | not exposed as a one-call tool yet; requires browser SDK/Identity port |
| Ethereum Safe create | live prepare mode; deploy requires configured relayer |
| Ethereum Safe send | not exposed yet |
| PRF/private key exposure | never exposed |
## Bridge Signing Model
1. The MCP transport is public. Possession of a session ID is not authorization to sign.
2. Create and Connect approval sessions remain open for 30 minutes; Sign remains open for 60 minutes. Approved
results remain agent-readable for 30 days. The start response's `expires_at` is authoritative; timestamps inside
the exact payload being signed do not shorten or extend the Connect session.
3. PRF output and derived Bitcoin/Arkade, Ethereum, and Nostr private keys exist only in transient browser
buffers. The browser follows `derive → sign/use → zeroize` in `finally`; secrets never enter MCP, Worker,
Durable Object, Arkade, Nostr plaintext, URL, or logs.
4. Create verifies the WebAuthn registration and obtains the PRF exactly like Nuri Expo, then calls Arkade auth with
the credential public key and client signer. The assertion for Arkade's returned challenge also claims an
available phone or email-derived username through `/arkade/lnurl/register`; no additional username-only assertion
is added. Connect then publishes the Nuri Expo encrypted Nostr recovery records. Completion requires at least one
relay acknowledgement per event.
5. Access derives the public identities, signs a proof bound to the exact session, challenge, RP ID, and derived EOA,
and approves the MCP result only after the Worker verifies that proof. The page then shows only **APPROVED** and
**show details**; public identities remain collapsed. Known credential hints add the full Arkade-bound WebAuthn
assertion/counter verification. Legacy passkeys without a stored credential public key still approve through the
verified EOA proof. Nostr is not queried and Arkade metadata is best-effort enrichment only.
6. Sign independently verifies the exact typed Ethereum or Nostr result before the MCP poll returns it. Mobile and
tablet browsers perform the current local passkey ceremony. Desktop browsers default to a locally generated QR of
the unchanged approval URL; they poll the same session until the phone approves. The small desktop-passkey link is
an explicit fallback. When a credential public key exists, Connect also verifies the assertion against its Arkade
binding and counter. Old passkeys without that public key use the verified wallet signature as the legacy proof;
missing keys and Nostr recovery failures are logged, but never block Sign.
7. Source `0.1.17` adds a Monerium-only deferred SIWE variant. One PRF assertion derives the EOA; Connect sends only
`request_id + EOA` to the fixed `https://monerium.nuri.com/api/connect/siwe` endpoint, receives one to eight exact
messages, signs all before the existing `finally` zeroizes the wallet, and returns only the verified EOA and
signatures. No Monerium OAuth credential enters Connect. The live flow cannot complete until that endpoint exists.
8. Public result material includes the derivation version, credential ID/public key, Bitcoin BIP86 and Nuri
MuSig2 addresses, the Nuri Expo Arkade contract, the separate classic Arkade contract, Nuri and Arkade server
public keys, EOA public key/address,
Nostr pubkey/`npub`, optional Lightning address, and the verified typed signature result.
9. Ethereum Safe deploy uses a configured relayer/paymaster for gas.
10. Arkade send/cosign uses the live v5 backend contract:
- `wallet_send_prepare` binds a direct send package to a WebAuthn challenge.
- The bridge browser produces the WebAuthn/PRF assertion.
- `wallet_send_cosign` forwards assertion fields and MuSig nonce requests to the live co-signer.
- `wallet_send_complete` acknowledges submitted funding transactions.
11. The connector does not expose a fake "pay invoice" shortcut. A one-call `lightning_pay_invoice` tool must run the real browser Arkade SDK/Identity path before it is listed.
```mermaid
flowchart LR
Caller[Agent or downstream MCP] -->|wallet create, connect, or sign| MCP[connect.nuri.com/mcp]
MCP --> Session[Short-lived session]
Session --> Browser[connect.nuri.com approval URL]
Browser --> Passkey[WebAuthn passkey + PRF]
Browser --> Arkade[Nuri Arkade public binding]
Browser --> ArkNode[Arkade node signer and timelocks]
Browser -.-> Nostr[Optional recovery during Access]
Browser -->|verified public result| Session
Session -->|poll| Caller
```
## MCP Setup
Use the Streamable HTTP endpoint:
```json
{
"mcpServers": {
"nuri-wallet-connector": {
"url": "https://connect.nuri.com/mcp"
}
}
}
```
Agents should call:
1. `initialize`
2. `tools/list`
3. `tools/call`
Raw initialize example:
```bash
curl -s -X POST https://connect.nuri.com/mcp \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "example-agent", "version": "1.0.0" }
}
}'
```
List tools:
```bash
curl -s -X POST https://connect.nuri.com/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
```
## MCP Tools
| Tool | Status | Moves Money | Requires Passkey | Requires Wallet Tuple | Purpose |
| --- | --- | --- | --- | --- | --- |
| `wallet_provider_info` | live | no | no | no | Describe the connector, bridge model, live tools, and MCP URL. |
| `wallet_create_start` | live | no | create + assertion | no | Create the same deterministic wallet as Nuri Expo and start recovery registration. |
| `wallet_create_result` | live | no | no | session id | Poll until Arkade binding and encrypted Nostr publication complete. |
| `wallet_sign_start` | live | depends on typed request | yes | optional credential hints | Start an allowlisted typed signing session. |
| `wallet_sign_result` | live | no additional action | no | session id | Poll and return only the independently verified result. |
| `wallet_backup_start` | live; requires Privy app config | no | fresh assertion + Privy login | expected EOA | Opt-in import of the existing PRF-derived EVM key into the user's own Privy wallet. |
| `wallet_backup_result` | live | no | no | session id | Poll a backup session; approved results carry only the backup status and non-secret identifiers. |
| `wallet_connect_sign_start` | live; completion waits for Monerium | no | one prompt | Monerium request id | Derive EOA, fetch all exact Monerium SIWE messages, and sign them before zeroize. |
| `wallet_connect_sign_result` | live; completion waits for Monerium | no | no | session id | Return only the verified EOA and SIWE signatures. |
| `wallet_link_start` | live | no | bridge browser yes | no | Start a short-lived browser passkey wallet-link session. |
| `wallet_link_result` | live | no | no | session id | Poll a wallet-link session until pending, approved, or expired. |
| `wallet_connect_start` | live alias | no | bridge browser yes | no | Product alias for `wallet_link_start`. |
| `wallet_connect_result` | live alias | no | no | session id | Product alias for `wallet_link_result`. |
| `arkade_info` | live | no | no | client pubkey only | Fetch live Arkade co-signer and recovery policy info. |
| `arkade_receive_invoice` | live | receive flow | no | yes | Create a Lightning receive invoice for the linked Arkade wallet. |
| `arkade_receive_sync` | live | no | no | yes | Poll receive state for the linked Arkade wallet. |
| `lightning_receive_invoice` | live alias | receive flow | no | yes | Product alias for `arkade_receive_invoice`. |
| `lightning_receive_sync` | live alias | no | no | yes | Product alias for `arkade_receive_sync`. |
| `wallet_send_prepare` | live v5 contract | prepares money movement | no | yes plus send package | Bind an already-built direct send package to a passkey challenge. |
| `wallet_send_cosign` | live v5 contract | co-signs money movement | bridge assertion fields required | yes plus send package | Forward bridge assertion and MuSig nonce request to live co-signer. |
| `wallet_send_complete` | live v5 contract | no new spend | no | challenge token | Complete send bookkeeping after submitted funding tx. |
| `ethereum_safe_create` | live prepare; relayer deploy | deploy spends relayer gas only | bridge browser for deploy | session id for deploy | Prepare or deploy a clean 2/2 Safe on Gnosis. |
### `wallet_provider_info`
Returns connector metadata and bridge instructions.
```json
{
"name": "wallet_provider_info",
"arguments": {}
}
```
Expected use:
1. Call this first when the agent is unsure what the connector can do.
2. Show the user that transport auth is not required; flows needing wallet key material complete through the bridge-browser passkey PRF signature.
### `wallet_create_start` / `wallet_create_result`
Create starts a Nuri Expo-compatible wallet ceremony. It does not return or persist a PRF output, seed,
private key, or Nostr `nsec`.
```json
{
"name": "wallet_create_start",
"arguments": {}
}
```
With no arguments, the browser asks for a phone number by default and allows switching to email. An agent that
already collected the identifier may prefill it:
```json
{
"name": "wallet_create_start",
"arguments": {
"identifier_type": "phone",
"identifier": "+49 151 23456789",
"return_url": "https://monerium.nuri.com/api/connect/create-complete?state=opaque-state"
}
}
```
The exact normalized phone/email becomes the passkey name. Phone input must begin with `+` and include the country
code; its digits become the authenticated Arkade username, so the example becomes `4915123456789@nuri.com`. For
email, the lowercase local part before `@` becomes the username when it satisfies Arkade's 3-32 character
`[a-z0-9._-]` contract; otherwise wallet creation continues without claiming a username.
The start result is `pending` and contains a short-lived `https://connect.nuri.com/create/...` URL. Give that
URL to the user, then poll:
```json
{
"name": "wallet_create_result",
"arguments": { "session_id": "..." }
}
```
The browser performs these bound steps:
1. Create a discoverable ES256 credential with PRF support for RP ID `nuri.com`. Registration and assertion
client data must use exactly `https://connect.nuri.com` or the Nuri Expo WebView base origin
`https://nuri.com`.
2. Hide the phone/email field as soon as Create begins. If the initial credential-create request is cancelled or
fails, restore it; once the credential exists, keep it hidden during the remaining stages.
3. Capture and server-verify the attestation credential ID and SPKI credential public key.
4. Request a PRF assertion and derive `nuri-expo-wallet-v1` Bitcoin BIP86, Arkade SingleKey, Nuri
Arkade/MuSig2 aggregate, EOA, and Nostr public identities.
5. Request a separate user-verified assertion for Arkade's fresh challenge, register the public credential/Arkade
binding, and claim the normalized phone or compatible email-local-part username when present.
6. Encrypt the full and exit recovery records with the Nuri Expo XChaCha20-Poly1305 contract.
7. Sign both NIP-78 records with the deterministic Nostr key and publish them to the configured relays.
8. Zeroize PRF and all derived private-key buffers in `finally`.
9. Replace the Create form with only clickable **SUCCESS**; selecting it expands the seven signer-labelled public
identities. When `return_url` was supplied, navigate the approving phone to a trusted Nuri URL or issue a
non-blocking browser `GET` to another HTTPS callback. Both omit credentials, referrer, and wallet/session data.
The normal new-wallet ceremony can therefore show three authenticator prompts: credential creation, the PRF
assertion used for deterministic wallet derivation, and Arkade's fresh binding assertion. The first two match the
Nuri Expo web passkey flow. The Arkade challenge is issued only after the PRF-derived client public key is known, so
the third assertion is a separate server-bound proof, not another wallet or an application message signature.
Create can report intermediate `binding` and `awaiting_nostr` states. It is `approved` only after Arkade
binding succeeds and both verified Nostr events have at least one relay acknowledgement. The approved result contains
the public wallet tuple, account/LNURL fields when present, and Nostr event IDs.
The Create bind, completion, and publication stages are idempotent per session. A browser may safely retry the
same stage after a network-level `Load failed`; a retry with different credential, wallet, or recovery event data
is rejected.
The encrypted full-recovery plaintext backs up the credential ID/public key, valid Bitcoin Nuri/BIP86 addresses,
valid Arkade Nuri/pure-SingleKey addresses, the checksummed Ethereum EOA, Nuri server/aggregate MuSig2 public keys,
Arkade node signer/network/timelocks, Nostr pubkey/`npub`, derivation version, and the existing Nuri Expo recovery
context. No private key or `nsec` is part of the plaintext.
`return_url` is an HTTPS continuation hint, not a result channel. A `nuri.com` URL opens on the approving phone;
other HTTPS callbacks receive the existing credential-free notification. Poll `wallet_create_result` with the
server-side session ID in either case. Connect never appends a credential, address, PRF, Nostr secret, Monerium
access credential, or other wallet material. `http://localhost` and `http://127.0.0.1` are accepted only locally.
The exposed Arkade SingleKey public identity is enough for a standard pure Arkade wallet to derive the same owner
from the same PRF. The connector's currently live Arkade send tools still implement the Nuri co-signed path; a
separate one-call pure-Arkade send tool is not claimed.
### `wallet_sign_start` / `wallet_sign_result`
Sign accepts only an allowlisted operation with a fixed payload shape:
| Operation | Required payload | Verified result |
| --- | --- | --- |
| `ethereum_personal_sign` | `{ "message": "..." }` | EIP-191 signature recovers the derived EOA. |
| `ethereum_personal_sign_bundle` | `{ "messages": ["...", "..."] }` | Every ordered EIP-191 signature recovers the same derived EOA and matches its exact stored message. |
| `ethereum_typed_data` | `{ "typed_data": { ... } }` | EIP-712 signature verifies for the exact typed data and EOA. |
| `ethereum_transaction` | `{ "transaction": { ... } }` | Serialized transaction signer and requested fields are rechecked. |
| `nostr_event` | `{ "event": { "kind", "created_at", "tags", "content" } }` | NIP-01 ID and Schnorr signature are recomputed. |
| `ethereum_personal_sign_deferred` | `{ "preparation_url": "https://..." }` | The approval page fetches `{ "message", "expires_at" }` from that URL when it opens, displays and binds the message byte for byte (bind-once; a changed re-bind is rejected), then the EIP-191 signature over exactly those bytes recovers the derived EOA. For challenges whose timestamp must be fresh at signing time. |
| `address_book` | `{ "reply_pubkey": "<64 hex>", "ops"?: [...] }` | The phone opens the Nuri address book and returns it encrypted to `reply_pubkey`; the wallet's EIP-191 signature over the session-bound ciphertext digest recovers the derived EOA. See below. |
Example for an Ethereum wallet-ownership proof:
```json
{
"name": "wallet_sign_start",
"arguments": {
"operation": "ethereum_personal_sign",
"payload": {
"message": "Link 0x... to Monerium profile ... at 2026-07-18T12:00:00Z"
},
"return_url": "https://monerium.nuri.com/api/connect/signature-complete?state=opaque-state"
}
}
```
Example for one-passkey Monerium authentication plus payment approval:
```json
{
"name": "wallet_sign_start",
"arguments": {
"operation": "ethereum_personal_sign_bundle",
"payload": {
"messages": [
"exact Monerium SIWE message",
"exact Monerium payment-order message"
]
},
"return_url": "https://monerium.nuri.com/api/connect/signature-complete?state=opaque-state"
}
}
```
Bundles contain one to eight non-empty messages. Each message is limited to 16 KiB and the complete normalized
payload to 32 KiB. Connect derives one EOA, signs every message during the same browser ceremony, then verifies
every returned signature and its position against the server-stored payload. One missing, reordered, substituted,
or invalid signature rejects the complete result.
Optional `cred_id_b64u` and `credential_pubkey_b64u` values pin the session to a known credential and enable the
full Arkade-bound WebAuthn assertion/counter check. If the public key is absent on a legacy passkey, encrypted Nostr
recovery runs only as background best-effort logging and never delays or blocks Sign. The Worker logs the legacy path
and still independently verifies the exact Ethereum or Nostr wallet signature before approval.
Optional `expected_eoa` (checksummed `0x` address) binds the session to one signer address: both approval paths fail
closed on any mismatch.
**Address book access (`address_book`).** For desktop tools that need the user's Nuri address book (nuri-expo
`tools/address-book`, nuri-com/nuri-expo#1583). The caller never holds an address book key and never pastes one.
- Payload: `reply_pubkey`, a fresh secp256k1 x-only public key (64 hex) the caller generated for this one request,
and optional `ops`: `{ "op": "add", "contact": {...} }`, `{ "op": "update", "id": "<32 hex>", "contact": {...} }`,
`{ "op": "delete", "id": "<32 hex>", "label": "<current name>" }`. `contact` is the app's contact input
(`label`, `destinations`, optional `note`, `nuriUsername`). Start normalizes each contact with the app's rules
and refuses an invalid one; `iban`, `e164` and `email` are always derived from `raw`, so the page shows exactly the
value that gets written.
- The approval page shows the request in plain words before the passkey prompt ("Share your address book with this
desktop", or each change with its name and destinations). There is no Privy path.
- After the passkey PRF the page derives the wallet exactly like Nuri Expo (PRF, Bitcoin key, wallet Nostr key,
address book key), reads the collection from the Expo relay set (`relay.nuri.com` first), takes the newest event
per contact, applies the changes (a delete must name the contact's current name; each write needs at least one relay
ack; before each write the page asks Connect whether the session is still open and checks the returned deadline
again right before publishing, so an expired or finished approval writes nothing; an add gets an id derived from the
session, so running the same approval again rewrites that contact instead of adding a second one), and encrypts
`{ format, address_book_pubkey, contacts, records, results }` to `reply_pubkey` (`records` lists every contact
record, deletions included, as `{ id, created_at, event_id }`, so a caller merges answers per contact with the
relays' rule: newest `created_at`, then lowest `event_id`):
key = HKDF-SHA256(x(ECDH(address book key, reply_pubkey)), salt `nuri-address-book-reply-v1`, info session id),
XChaCha20-Poly1305. With `expected_eoa` set, a different passkey account stops before any relay I/O.
- The page submits only `{ address_book_pubkey, nonce_b64u, ciphertext_b64u, signature }`. The Worker accepts exactly
these keys and verifies `signature` (EIP-191 by the wallet EOA, bound to the session id and the ciphertext SHA-256),
so it never sees the PRF, any private key, the address book key, or the contact list. A requested change is visible
to Connect because it is part of the request the page shows.
- `wallet_sign_result` returns that ciphertext; the caller decrypts it with its one-time key. Pending and expiry work
like every sign session: a cancelled passkey prompt submits nothing and the session expires.
- `test/fixtures/address-book-vector.json` is a frozen vector shared with nuri-expo
(`lib/addressBook/connectVector.json`).
**Sign with Privy.** When `PRIVY_APP_ID` is configured, the passkey sign page links to
`/signature/:session_id/privy` (`/sign/:session_id/privy` aliases it), a Privy login page that lets a user who ran
`wallet_backup_start` approve the same request with their imported Privy wallet — the same EOA, verified server-side
by the exact same signature checks. Only `ethereum_personal_sign`, `ethereum_personal_sign_bundle`,
`ethereum_typed_data`, and a deferred session already bound via `/api/wallet_sign_prepare` are eligible;
`ethereum_transaction`, `nostr_event`, and Monerium connect-sign sessions stay passkey-only. The browser posts
`POST /api/wallet_sign_complete_privy` with `{ session_id, signer: { eth_user_eoa, privy_user_id, privy_wallet_id },
result }`; the body is key-whitelisted (`sign_privy_payload_invalid`) and the verified result is stored like any
sign approval. `wallet_sign_result` then returns `signer: "privy"`, a partial `wallet: { eth_user_eoa }`, and
`privy: { user_id, wallet_id }`. Passkey-approved results carry `signer: "passkey"` and are otherwise unchanged.
On mobile and tablet, the approval page shows **Sign with Passkey Wallet**. On desktop, it locally renders the exact
unchanged approval URL as a QR code, polls the same session, and shows **Signed** after the phone completes. A small
**Sign with desktop passkey** link remains at the bottom as an explicit fallback. Device classification changes only
presentation; both paths use the same session and server verification.
The QR is an approval-URL handoff, not WebAuthn's native cross-device QR. The phone loads the existing session's
server-stored challenge and typed payload, evaluates PRF, derives and uses the wallet only in transient phone-browser
memory, then zeroizes it. Only assertion fields, public wallet material, and the typed signature result return to
Connect. Connect verifies them against the stored request, completes that same session, and the desktop learns only
through polling. Native WebAuthn hybrid authentication instead returns the PRF result, when supported, to the
requesting desktop page, moving wallet derivation and signing to the desktop. Its PRF interoperability also varies by
browser and authenticator, so it remains an explicit fallback rather than the phone-first trust boundary.
The calling product must show the exact operation and payload before it sends the user to Connect. A start response
includes the authoritative lifetime:
```json
{
"status": "pending",
"session_id": "...",
"approval_url": "https://connect.nuri.com/signature/...",
"expires_at": 1784491200000,
"expires_in_seconds": 3600
}
```
`expires_at` governs the approval URL. Do not infer a five-minute Connect lifetime from a SIWE `Expiration Time`
inside the signed payload. Poll with:
```json
{
"name": "wallet_sign_result",
"arguments": { "session_id": "..." }
}
```
An approved single EIP-191 result contains `result.address`, the exact `result.message`, and `result.signature`.
A bundle contains the common `result.address` and ordered
`result.signatures[] = { "message": "...", "signature": "0x..." }`. With a trusted Nuri `return_url`, the
approving phone navigates there and the integrating server polls the result. For another HTTPS callback, the browser
sends the Worker-verified result as a credential-free `text/plain` JSON POST:
```json
{
"format": "nuri-connect-signature-callback-v1",
"status": "approved",
"session_id": "...",
"operation": "ethereum_personal_sign",
"result": {
"address": "0x...",
"message": "I hereby declare that I am the address owner.",
"signature": "0x..."
}
}
```
The opaque `state` remains in the callback URL, never in the signed payload. The receiver must bind it to the
stored Connect session and verify the signature against the exact expected address and message. A callback delivery
failure does not invalidate the approved signature; use `wallet_sign_result` as the bounded fallback. The session
result never contains PRF output or private keys.
### `wallet_backup_start` / `wallet_backup_result`
Backup is the opt-in recovery path for an existing Nuri passkey wallet. It imports **only** the PRF-derived EVM
private key into the user's own Privy wallet as a second signing/recovery path — the EOA does not change, no assets
move, and no Bitcoin, Nostr, Arkade, Safe, or MuSig2 key material is touched. The flow needs a configured
`PRIVY_APP_ID` on the worker; without it every start call fails with `privy_not_configured`.
```json
{
"name": "wallet_backup_start",
"arguments": {
"expected_eoa": "0x...",
"privy_user_id": "did:privy:...",
"cred_id_b64u": "...",
"return_url": "https://app.nuri.com/backup/continue?state=opaque-state"
}
}
```
Only `expected_eoa` is required: the checksummed `0x` address the passkey is expected to derive. `privy_user_id`
optionally pins which Privy account may complete the backup. The start result is `pending` and contains a
30-minute `https://connect.nuri.com/backup/...` URL; poll `wallet_backup_result` with the session id.
The browser page performs these bound steps:
1. Load the session, then authenticate the user into the Nuri Privy app (`email` and `wallet` login methods;
embedded-wallet auto-creation is `off`, so the page can never mint a new EOA).
2. If `privy_user_id` was pinned, refuse any other Privy account and offer **Use another account**.
3. On the explicit **Back up with Privy** confirmation, request a fresh user-verified PRF assertion and derive
`nuri-expo-wallet-v1`. If the derived `eth_user_eoa` is not `expected_eoa`, the flow fails closed before any
Privy call.
4. If an imported Privy wallet with the exact address already exists, report `already_backed_up` without calling
import again. Otherwise call Privy's `importWallet` with the hex key — the only place the key ever leaves the
derivation — and verify the returned address equals `expected_eoa`.
5. Sign the session-bound `wallet_connect` proof with the derived key; the worker re-verifies it as evidence that
the browser really derived the expected EOA.
6. Zeroize the PRF and all derived key buffers in `finally`, then POST the whitelisted completion shape.
The worker enforces the invariants:
- `POST /api/wallet_backup_complete` accepts only the keys `session_id`, `wallet`, `cred_id_b64u`,
`client_data_b64u`, `auth_data_b64u`, `sig_b64u`, optional `credential_pubkey_b64u`, `result`, and `backup`;
`backup` itself may only contain `status`, `imported_eoa`, `privy_wallet_id`, `privy_user_id`. Anything else —
`private_key`, `prf`, `entropy`, … — is rejected with `backup_payload_invalid` before anything is stored.
- The passkey assertion is verified exactly like a Connect approval: fresh challenge, `requested_cred_id_b64u`
binding, and WebAuthn verification when a credential public key is available.
- `backup.imported_eoa` must equal `expected_eoa`, and a pinned `privy_user_id` must match the completing user.
The approved result contains only `status` (`imported` or `already_backed_up`), `imported_eoa`,
`privy_wallet_id`, `privy_user_id`, plus the session's public wallet tuple. UX copy on the page: **Back up your
wallet** — "Keep access to your existing wallet even if you lose this passkey. Your wallet address will not
change." — and the confirmation states that Privy receives a protected copy of the Ethereum key it can use inside
its wallet infrastructure.
```bash
curl -s -X POST https://connect.nuri.com/api/wallet_backup_start \
-H 'content-type: application/json' -d '{"expected_eoa":"0x..."}'
curl -s -X POST https://connect.nuri.com/api/wallet_backup_result \
-H 'content-type: application/json' -d '{"session_id":"..."}'
```
Required Worker env:
| Env | Purpose |
| --- | --- |
| `PRIVY_APP_ID` | Production Nuri Privy app id. Without it, backup start fails with `privy_not_configured`. |
| `PRIVY_CLIENT_ID` | Optional Privy client id passed to the page's `PrivyProvider`. |
**Passkey-first since 0.1.26** (issue #30): opening the page fires the passkey assertion immediately
(the credential hint pre-selects the owning passkey — one prompt, no chooser), verifies the derived EOA, and only
then runs the Privy login and import automatically. No confirmation button in between; a dismissed Privy modal
gets a "Continue with Privy" retry button. See [docs/privy-backup.md](./privy-backup.md) for the verified flow.
### `GET /@<username>/backup` — permanent username backup link
A sendable link that never expires: `https://connect.nuri.com/@emino/backup`. Each visit mints a fresh 30-minute
backup session and renders it directly — no redirect, so the permanent URL stays in the address bar and a reload
mints a new session instead of reopening a consumed one. (Sessions are single-use: the WebAuthn challenge is bound
at start and consumed at completion, so "keep one session alive forever" is structurally wrong.)
The worker resolves the username to its EOA through the public `<username>.nuri.eth` CCIP-Read text record
(`com.nuri.evm`) — the same directory the app's public `/pay/@username` page reads — and pins the minted session to
that `expected_eoa`, so every existing backup invariant holds. It also fetches the arkade credential hint
(`GET /arkade/lnurl/credential-hint/<username>`) so the page pre-selects the owning passkey (one tap instead of an
account chooser); a missing hint simply mints the session without it.
Unknown usernames (empty on-chain record), lookup outages, a missing `PRIVY_APP_ID`, a bad `return_url`, and a
malformed name all land on the same plain unavailable page (HTTP 410 HTML) — never raw JSON, never a reason which
case it was, so the link is not a username-enumeration oracle. An optional `?return_url=` query carries a trusted
HTTPS continuation into the minted session (same `normalizeReturnUrl` rule as every other starter).
```bash
curl -s -o /dev/null -w "%{http_code}\n" https://connect.nuri.com/@emino/backup
```
### `wallet_link_start`
Creates a short-lived wallet-link session and returns an approval URL.
```json
{
"name": "wallet_link_start",
"arguments": {
"cred_id_b64u": "optional-known-credential-id",
"credential_pubkey_b64u": "optional-known-SPKI-public-key",
"return_url": "https://business.nuri.com/api/app/continue/opaque-claim"
}
}
```
Response shape:
```json
{
"status": "pending",
"session_id": "...",
"approval_url": "https://connect.nuri.com/approve/...",
"expires_at": 1782833911990
}
```
Agent behavior:
1. Give the `approval_url` to the user.
2. Tell the user to open it on a passkey-capable Nuri device/browser.
3. Do not ask the user to paste PRF output or private keys.
4. Pass credential hints from a prior approved Create when available for the additional WebAuthn/Arkade binding
verification.
5. Poll `wallet_link_result` with the returned `session_id`.
Use Access only when the caller does not yet know the user's EOA. If an approved Create or earlier Access already
provided the public wallet tuple, retain it in the trusted application context and go directly to the typed Sign
flow. Do not make the user identify the same wallet again for every signature.
After the passkey PRF assertion, Access derives the public wallet and signs a proof bound to the exact session,
challenge, RP ID, and derived EOA. Connect verifies that signature before `wallet_link_result` becomes `approved`.
The page shows only **APPROVED** and an underlined **show details** control; the public identities remain collapsed by
default. A known credential public key adds the full WebAuthn assertion, Arkade binding, and counter verification.
For legacy passkeys without it, the verified session-bound EOA proof is sufficient and Connect logs the legacy path.
Nostr is not queried, and optional Arkade public enrichment cannot block approval. After approval, an optional
trusted Nuri `return_url` opens on the same phone; another HTTPS callback receives a credential-free `GET`. The
continuation carries no wallet result, so the server must still poll `wallet_link_result`.
### `wallet_link_result`
Polls a link session.
```json
{
"name": "wallet_link_result",
"arguments": {
"session_id": "..."
}
}
```
Possible states:
| State | Meaning |
| --- | --- |
| `pending` | User has not approved yet. |
| `approved` | Returns the wallet tuple. The result remains readable for 30 days so follow-up tools can reuse the same `session_id`. |
| `expired` | Session timed out; start a new one. |
Approved response includes:
```json
{
"status": "approved",
"wallet": {
"cred_id_b64u": "...",
"client_public_key_33_hex": "02...",
"eth_user_eoa": "0x..."
}
}
```
### `wallet_connect_start` / `wallet_connect_result`
These are product-name aliases for `wallet_link_start` and `wallet_link_result`.
Use them when an agent is presenting the flow as "Nuri Wallet Connect" instead of a low-level link session.
### `wallet_connect_sign_start` / `wallet_connect_sign_result`
This Monerium-only flow removes separate Access-then-Sign prompts for an unknown EOA:
```json
{
"name": "wallet_connect_sign_start",
"arguments": {
"request_id": "opaque_nuri_monerium_session_id",
"return_url": "https://business.nuri.com/api/app/continue/opaque-claim"
}
}
```
The browser performs one WebAuthn/PRF assertion. While the derived wallet still exists in transient memory, Connect
posts `{ "request_id", "eoa" }` to fixed endpoint `https://monerium.nuri.com/api/connect/siwe`. Expected response:
```json
{ "messages": ["exact Base SIWE", "exact Gnosis SIWE"] }
```
Connect accepts one to eight messages. It binds their exact bytes and EOA to the session, signs every message, verifies
every signature, and zeroizes the wallet in the existing `finally`. Poll with:
```json
{
"name": "wallet_connect_sign_result",
"arguments": { "session_id": "..." }
}
```
Approved output contains `status`, `session_id`, `expires_at`, and
`result = { operation, address, signatures[] }`; it omits the broader wallet tuple. Connect never receives a Monerium
OAuth credential. End-to-end completion waits for `nuri-monerium` to implement the fixed endpoint.
### `ethereum_safe_create`
Prepares a deterministic clean 2/2 Safe:
1. Owner 1: passkey-derived `eth_user_eoa`.
2. Owner 2: Nuri server EOA from Worker env.
3. Threshold: `2`.
4. Network: Gnosis Chain, chain id `100`.
5. Modules: none.
Prepare-only call without a passkey session:
```json
{
"name": "ethereum_safe_create",
"arguments": {
"user_eoa": "0x1111111111111111111111111111111111111111"
}
}
```
Paymaster/relayer deploy call after wallet approval:
```json
{
"name": "ethereum_safe_create",
"arguments": {
"session_id": "...",
"deploy": true,
"wait_for_receipt": false
}
}
```
Agent behavior for deploy:
1. Call `wallet_link_start`.
2. Give the approval URL to the user.
3. Poll `wallet_link_result` until approved, or call `ethereum_safe_create` directly with the same `session_id`.
4. `wallet_link_result` does not consume the session; approved sessions remain readable until expiry.
Paymaster model:
1. The paymaster is a relayer that pays Gnosis deployment gas.
2. The relayer is not a Safe owner.
3. The relayer cannot move Safe funds.
4. The deployed Safe still requires both owners for future spending.
Required Worker env for deploy:
| Env | Purpose |
| --- | --- |
| `ETH_SERVER_OWNER_ADDRESS` | Public Nuri server owner address. |
| `ETH_SERVER_OWNER_PRIVATE_KEY` | Alternative source for the server owner address; future send tools will need signing. |
| `ETH_RELAYER_PRIVATE_KEY` | Pays deployment gas. Must hold xDAI on Gnosis. |
| `ETH_GNOSIS_RPC_URL` | Optional RPC override. Defaults to the repo Gnosis RPC. |
If `deploy` is false or omitted, no gas is spent and no relayer secret is required.
### `arkade_info`
Fetches live Arkade policy info for a compressed client public key.
```json
{
"name": "arkade_info",
"arguments": {
"client_public_key_33_hex": "021111111111111111111111111111111111111111111111111111111111111111"
}
}
```
Optional:
```json
{
"cred_id_b64u": "existing-passkey-credential-id"
}
```
Validation:
1. `client_public_key_33_hex` must be a compressed secp256k1 pubkey.
2. Invalid keys return JSON-RPC `-32602`.
3. Upstream failures are returned as MCP tool errors.
### `arkade_receive_invoice`
Creates a real Lightning receive invoice for an existing Nuri Arkade wallet tuple.
```json
{
"name": "arkade_receive_invoice",
"arguments": {
"cred_id_b64u": "...",
"client_public_key_33_hex": "02...",
"amount_sats": 2000,
"memo": "optional memo"
}
}
```
Limits:
1. `amount_sats` must be an integer.
2. Minimum: `1`.
3. Maximum: `1000000`.
4. The tool calls the live Arkade backend.
### `arkade_receive_sync`
Polls receive state for the linked Arkade wallet.
```json
{
"name": "arkade_receive_sync",
"arguments": {
"cred_id_b64u": "...",
"client_public_key_33_hex": "02..."
}
}
```
Use after creating or paying a receive invoice.
`lightning_receive_invoice` and `lightning_receive_sync` are product-name aliases for the same live receive endpoints.
### `wallet_send_prepare`
Prepares a live Arkade v5 direct-send approval challenge.
This is not a mock approval. It calls `POST /api/arkade/send/prepare` on the live Nuri Arkade backend.
It requires an already-built direct send package, because invoice-to-swap-to-PSBT construction still has to run through the real Arkade SDK/Identity path.
```json
{
"name": "wallet_send_prepare",
"arguments": {
"cred_id_b64u": "...",
"client_public_key_33_hex": "02...",
"send_intent_id": "...",
"psbt_b64": "...",
"approval_summary": {
"kind": "lightning_invoice_pay",
"title": "Approve Lightning Payment",
"amount_sats": 2100,
"final_amount_sats": 2000
}
}
}
```
Expected successful response includes the upstream challenge fields:
```json
{
"ok": true,
"challenge_token": "...",
"challenge": "...",
"rp_id": "nuri.com",
"origin": "https://nuri.com",
"send_intent_id": "..."
}
```
### `wallet_send_cosign`
Submits the bridge-browser assertion and direct MuSig cosign request to the live Arkade v5 co-signer.
The browser must produce:
1. WebAuthn client data.
2. Authenticator data.
3. Authenticator signature.
4. PRF-derived client signer key and client MuSig nonce material.
```json
{
"name": "wallet_send_cosign",
"arguments": {
"challenge_token": "...",
"cred_id_b64u": "...",
"client_public_key_33_hex": "02...",
"send_intent_id": "...",
"psbt_b64": "...",
"client_data_b64u": "...",
"auth_data_b64u": "...",
"sig_b64u": "...",
"cosign_requests": [
{
"msg32": "...",
"client_pk33": "02...",
"client_pub_nonce": "..."
}
]
}
}
```
The upstream response returns server partial signatures. The client side still has to verify the server partial, aggregate the final MuSig signature, and let the Arkade SDK broadcast/submit.
### `wallet_send_complete`
Completes live Arkade v5 send bookkeeping after the funding transaction has been submitted.
```json
{
"name": "wallet_send_complete",
"arguments": {
"challenge_token": "...",
"send_intent_id": "...",
"txid": "optional funding transaction id"
}
}
```
## REST API
### Public documentation and discovery
| Route | Purpose |
| --- | --- |
| `GET /` | Nuri wallet landing page with Access and Create actions. |
| `GET /access` | Stable Access-only page. |
| `GET /create` | Phone/email passkey-wallet Create form. |
| `GET /export/` | Browser-only export for the Nuri passkey-derived Ethereum wallet and linked Privy Ethereum wallets. The key is shown for copying and cleared after 60 seconds; the worker never receives key material. |
| `GET /@<username>/backup` | Permanent username backup link: mints a fresh 30-minute backup session per visit (see `wallet_backup_start`). |
| `GET /mcp` | Human-readable MCP details at the same URL used by MCP clients. |
| `GET /documentation` | Human-readable documentation page generated from this file. |
| `GET /docs` | Alias for `/documentation`. |
| `GET /api/docs-raw` | Raw Markdown documentation for agents and tooling. |
| `GET /.well-known/mcp.json` | Machine-readable MCP server card. |
| `GET /llms.txt` | Short agent discovery file pointing to docs, MCP, and capabilities. |
### `GET /healthz`
Returns Worker health.
```bash
curl -s https://connect.nuri.com/healthz
```
### `GET /capabilities`
Returns the REST manifest and the currently documented MCP tools.
```bash
curl -s https://connect.nuri.com/capabilities
```
### `POST /api/wallet_create_start` / `POST /api/wallet_create_result`
REST equivalents of `wallet_create_start` and `wallet_create_result`:
```bash
curl -s -X POST https://connect.nuri.com/api/wallet_create_start \
-H 'content-type: application/json' -d '{}'
curl -s -X POST https://connect.nuri.com/api/wallet_create_result \
-H 'content-type: application/json' -d '{"session_id":"..."}'
```
Optional server-side prefill uses the same body as MCP:
```bash
curl -s -X POST https://connect.nuri.com/api/wallet_create_start \
-H 'content-type: application/json' \
-d '{"identifier_type":"phone","identifier":"+49 151 23456789","return_url":"https://monerium.nuri.com/api/connect/create-complete?state=opaque-state"}'
```
### `POST /api/wallet_sign_start` / `POST /api/wallet_sign_result`
REST equivalents of `wallet_sign_start` and `wallet_sign_result`:
```bash
curl -s -X POST https://connect.nuri.com/api/wallet_sign_start \
-H 'content-type: application/json' \
-d '{"operation":"ethereum_personal_sign","payload":{"message":"exact message"}}'
curl -s -X POST https://connect.nuri.com/api/wallet_sign_start \
-H 'content-type: application/json' \
-d '{"operation":"ethereum_personal_sign_bundle","payload":{"messages":["exact SIWE message","exact payment message"]}}'
curl -s -X POST https://connect.nuri.com/api/wallet_sign_result \
-H 'content-type: application/json' -d '{"session_id":"..."}'
```
The other `/api/wallet_create_*`, `/api/wallet_sign_complete`, and `/api/wallet_session` routes are internal
bridge-browser protocol endpoints. External MCP consumers should not call them directly.
### Combined Monerium login REST routes
- `POST /api/wallet_connect_sign_start` with `{ "request_id": "..." }`
- Browser-internal `POST /api/wallet_connect_sign_messages` with `{ "session_id": "...", "eoa": "0x..." }`
- `POST /api/wallet_connect_sign_result` with `{ "session_id": "..." }`
They use the same Durable Object and verification path as MCP and are live in `0.1.17`. Session start works now;
approval completion requires the fixed Monerium SIWE endpoint.
### `POST /api/wallet_link_start`
REST equivalent of `wallet_link_start`.
Product alias: `POST /api/wallet_connect_start`.
```bash
curl -s -X POST https://connect.nuri.com/api/wallet_link_start \
-H 'content-type: application/json' \
-d '{
"cred_id_b64u":"optional-known-credential-id",
"credential_pubkey_b64u":"optional-known-SPKI-public-key",
"return_url":"https://business.nuri.com/api/app/continue/opaque-claim"
}'
```
Both fields are optional. Supplying the trusted pair from an approved Create enables the additional WebAuthn/Arkade
binding verification. Without it, a legacy passkey completes through the session-bound EOA proof. Neither path
depends on Nostr recovery availability.
### `POST /api/wallet_link_result`
REST equivalent of `wallet_link_result`.
Product alias: `POST /api/wallet_connect_result`.
```bash
curl -s -X POST https://connect.nuri.com/api/wallet_link_result \
-H 'content-type: application/json' \
-d '{"session_id":"..."}'
```
### `POST /api/arkade/info`
REST equivalent of `arkade_info`.
```bash
curl -s -X POST https://connect.nuri.com/api/arkade/info \
-H 'content-type: application/json' \
-d '{"client_public_key_33_hex":"021111111111111111111111111111111111111111111111111111111111111111"}'
```
### `POST /api/arkade/receive/invoice`
REST equivalent of `arkade_receive_invoice`.
Product alias: `POST /api/lightning/receive/invoice`.
```bash
curl -s -X POST https://connect.nuri.com/api/arkade/receive/invoice \
-H 'content-type: application/json' \
-d '{
"cred_id_b64u": "...",
"client_public_key_33_hex": "02...",
"amount_sats": 2000,
"memo": "test receive"
}'
```
### `POST /api/arkade/receive/sync`
REST equivalent of `arkade_receive_sync`.
Product alias: `POST /api/lightning/receive/sync`.
```bash
curl -s -X POST https://connect.nuri.com/api/arkade/receive/sync \
-H 'content-type: application/json' \
-d '{
"cred_id_b64u": "...",
"client_public_key_33_hex": "02..."
}'
```
### `POST /api/arkade/send/prepare`
REST equivalent of `wallet_send_prepare`.
Product alias: `POST /api/wallet/send/prepare`.
```bash
curl -s -X POST https://connect.nuri.com/api/wallet/send/prepare \
-H 'content-type: application/json' \
-d '{
"cred_id_b64u": "...",
"client_public_key_33_hex": "02...",
"send_intent_id": "...",
"psbt_b64": "..."
}'
```
### `POST /api/arkade/send/cosign`
REST equivalent of `wallet_send_cosign`.
Product alias: `POST /api/wallet/send/cosign`.
```bash
curl -s -X POST https://connect.nuri.com/api/wallet/send/cosign \
-H 'content-type: application/json' \
-d '{
"challenge_token": "...",
"cred_id_b64u": "...",
"client_public_key_33_hex": "02...",
"send_intent_id": "...",
"psbt_b64": "...",
"client_data_b64u": "...",
"auth_data_b64u": "...",
"sig_b64u": "...",
"cosign_requests": []
}'
```
### `POST /api/arkade/send/complete`
REST equivalent of `wallet_send_complete`.
Product alias: `POST /api/wallet/send/complete`.
```bash
curl -s -X POST https://connect.nuri.com/api/wallet/send/complete \
-H 'content-type: application/json' \
-d '{
"challenge_token": "...",
"send_intent_id": "...",
"txid": "..."
}'
```
### `POST /api/ethereum/safe/create`
REST equivalent of `ethereum_safe_create`.
Prepare-only:
```bash
curl -s -X POST https://connect.nuri.com/api/ethereum/safe/create \
-H 'content-type: application/json' \
-d '{"user_eoa":"0x1111111111111111111111111111111111111111"}'
```
Paymaster deploy after approval:
```bash
curl -s -X POST https://connect.nuri.com/api/ethereum/safe/create \
-H 'content-type: application/json' \
-d '{"session_id":"...","deploy":true}'
```
## Recommended Agent Flow
### Create a wallet
1. Call `wallet_create_start`, optionally with the phone/email prefill.
2. Give the user the returned `/create/...` URL.
3. Poll `wallet_create_result` until `approved`, `expired`, or a terminal error.
4. For phone Create, require the returned account's normalized username and `@nuri.com` Lightning address. For
email, accept the address when its local part was compatible and successfully claimed.
5. Keep only the returned public wallet/account tuple in the trusted caller context.
### Sign a typed request
1. Construct and show the exact allowlisted operation and payload in the calling product; never request a generic
digest signature.
2. Call `wallet_sign_start` and give the user the returned `/signature/...` URL unchanged. On desktop Connect shows
its phone QR by default; mobile/tablet shows **Sign with Passkey Wallet**.
3. Poll `wallet_sign_result` automatically until `approved`, the returned authoritative `expires_at`, or a terminal
error. Never ask the user to reply `Signed`; the result tool is the source of truth.
4. Compare the returned operation, address, messages, and ordering to the caller's server-side correlation record.
5. Consume the verified `result` exactly once; do not independently trust a browser-reported address.
For Monerium wallet linking, follow [`monerium-mcp-integration.md`](./monerium-mcp-integration.md).
### Link a wallet
1. Call `wallet_provider_info`.
2. Call `wallet_link_start`, passing known `cred_id_b64u` and `credential_pubkey_b64u` from Create when available.
3. Give the user the `approval_url`.
4. Poll `wallet_link_result`.
5. Store the returned public wallet tuple in the trusted application context and reuse it. A missing Nostr backup or
legacy credential public key never blocks Access approval; legacy completion requires the verified session-bound
EOA proof.
### Receive Lightning
1. Ensure a wallet tuple exists.
2. Call `lightning_receive_invoice` or `arkade_receive_invoice`.
3. Show the returned invoice/payment request to the payer.
4. Call `lightning_receive_sync` or `arkade_receive_sync` until the receive state is final or clearly pending.
### Prepared Arkade Send
Use this only when another real component already built the Arkade direct send package.
1. Ensure a wallet tuple exists.
2. Build the direct send package with the real Arkade SDK/Identity path.
3. Call `wallet_send_prepare`.
4. In the bridge browser, perform the WebAuthn/PRF ceremony for the returned challenge.
5. Call `wallet_send_cosign` with assertion fields and MuSig nonce request(s).
6. Verify and aggregate signatures client-side.
7. Submit the funding transaction through the Arkade SDK.
8. Call `wallet_send_complete`.
### Create Ethereum Safe
Prepare-only:
1. Call `ethereum_safe_create` with `user_eoa`.
2. Show the returned counterfactual Safe address.
3. No gas is spent.
Paymaster deploy:
1. Call `wallet_link_start`.
2. User completes the passkey PRF signature in the bridge browser.
3. Call `ethereum_safe_create` with `session_id` and `deploy:true`.
4. The configured relayer pays deployment gas.
5. Future Safe spending still needs user owner plus server owner signatures.
### Do not do this
1. Do not ask the user for PRF output.
2. Do not ask the user for private keys.
3. Do not treat `/capabilities` as MCP; MCP is JSON-RPC `POST /mcp`.
4. Do not claim one-call `lightning_pay_invoice` support until the connector itself runs the real invoice-to-swap-to-PSBT SDK path.
5. Do not build a mock approval flow for product demos.
6. Do not reimplement Nuri key derivation, passkey PRF handling, or a signing bridge in a downstream MCP.
## Error Model
MCP errors use JSON-RPC error objects:
| Code | Meaning |
| --- | --- |
| `-32600` | Invalid JSON-RPC request. |
| `-32601` | Method not found. |
| `-32602` | Tool argument validation or tool execution error. |
REST errors return JSON with an `error` string and an HTTP status.
## Verification
Local checks:
```bash
npm run typecheck
npm test
```
Live checks:
```bash
curl -s https://connect.nuri.com/healthz
curl -s https://connect.nuri.com/capabilities
curl -s -X POST https://connect.nuri.com/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```
For any claim that the public MCP is live, verify all three:
1. `GET /healthz` succeeds.
2. `POST /mcp` `initialize` succeeds.
3. `POST /mcp` `tools/list` shows the documented tools.
## Documentation Maintenance
This file must be updated in the same commit whenever any of these change:
1. `MCP_TOOLS` in `src/worker.ts`.
2. REST routes in `workerFetch`.
3. `/capabilities` response shape.
4. Wallet-link approval behavior.
5. Create, Sign, credential-public-key, or Nostr recovery behavior.
6. Ethereum Safe owner derivation or paymaster deploy behavior.
7. Arkade upstream request/response expectations.
8. Tool limits, especially `amount_sats`.
9. Bridge/signing status: anything that moves from "not exposed" to "live".
Required review checklist for documentation changes:
1. Does the doc separate REST from MCP?
2. Does it say whether a tool moves money?
3. Does it say whether bridge-browser passkey PRF signature is required?
4. Does it include a copy-pasteable MCP example?
5. Does it avoid claiming live behavior that was not verified?
6. Do tests cover tool names and public capability names?
## Source Files
| Concern | File |
| --- | --- |
| Cloudflare Worker, REST, MCP | `src/worker.ts` |
| Typed wallet/session validation | `src/server/wallet-session.ts` |
| WebAuthn registration verification | `src/server/webauthn-registration.ts` |
| WebAuthn assertion verification | `src/server/webauthn-verify.ts` |
| Typed result verification | `src/server/wallet-result.ts` |
| Browser approval client | `src/browser/approval-client.ts` |
| Nuri Expo derivation and recovery contract | `src/browser/wallet-compat.ts` |
| Bundled browser client | `src/generated/approval-client.ts` |
| Ethereum Safe adapter | `src/adapters/ethereum-safe.ts` |
| Gnosis/Safe config | `src/config/gnosis.ts` |
| MCP tests | `test/worker-mcp.test.ts` |
| Wallet-link tests | `test/wallet-link.test.ts` |
| Expo compatibility tests | `test/wallet-compat.test.ts` |
| WebAuthn binding tests | `test/webauthn-registration.test.ts`, `test/webauthn-spki.test.ts` |
| Deployment config | `wrangler.toml` |