26 KiB
Agent Client Reference — n_signer
This document is the complete reference for agents, AI tools, and automated processes that need to call n_signer — a signing oracle that holds BIP-39 keys in locked memory and exposes them over multiple transports.
Table of Contents
- Architecture overview
- Wire protocol (all transports)
- Transport: Qubes qrexec (recommended for cross-qube)
- Transport: Local Unix abstract socket
- Transport: TCP (FIPS mesh)
- Transport: HTTP
- Transport: USB/serial (hardware signers)
- Transport: Stdio (one-shot)
- Auth envelope (kind-27235)
- Complete verb reference
- Algorithm reference
- Error codes
- End-to-end: publish a Nostr event
- End-to-end: sign arbitrary data
- End-to-end: PQ KEM encapsulate/decapsulate
- Discovery: finding running signers
- Prerequisites and setup
- Reference files in this repo
1. Architecture overview
┌─────────────────────────────────────────────────────────────┐
│ Caller (agent) │
│ Python / Node / C / Shell / any language │
│ Builds framed JSON-RPC → sends over transport → reads resp │
└──────────┬──────────────────────────────────────┬───────────┘
│ │
┌─────┴──────┐ ┌──────────┴──────────┐
│ qrexec │ │ TCP / HTTP / Unix │
│ (Qubes) │ │ Socket / USB │
└─────┬──────┘ └──────────┬──────────┘
│ │
┌─────┴──────────────────────────────────────┴───────────┐
│ n_signer │
│ BIP-39 mnemonic in mlock'd RAM │
│ Derives keys on demand (secp256k1, ed25519, PQ, etc.) │
│ Enforces policy + approval per caller │
└─────────────────────────────────────────────────────────┘
The signer is a single foreground process attached to a terminal. It holds the mnemonic in locked memory only — nothing touches disk. When the process exits, all state is destroyed.
2. Wire protocol (all transports)
Every transport uses the same framing and JSON-RPC contract.
2.1 Framing
[4 bytes: big-endian payload length N][N bytes: UTF-8 JSON payload]
- Length prefix is
uint32_tin network byte order. - Payload is exactly
Nbytes of UTF-8 JSON. - One request frame → one response frame per connection (except HTTP which uses standard HTTP request/response).
2.2 Request shape
{
"id": "<caller-supplied string, echoed in response>",
"method": "<verb name>",
"params": [ <arg0>, <arg1>, ..., { <options> } ]
}
id— any string. Used to match async responses. Echoed verbatim.method— one of the verbs in §10.params— JSON array. Positional args first; last element is conventionally an options object.
2.3 Response shape
Success:
{ "id": "<string>", "result": "<value>" }
Error:
{ "id": "<string>", "error": { "code": <int>, "message": "<string>" } }
result is always a JSON string. For structured verbs (like get_public_key with algorithm), the string is itself serialized JSON — parse it again.
2.4 Auth envelope (required for TCP/HTTP, optional for qrexec)
TCP and HTTP listeners require a kind-27235 auth envelope in the auth field. See §9.
3. Transport: Qubes qrexec (recommended for cross-qube)
Best for: Agents running in a caller qube, talking to n_signer in a dedicated signer qube.
Auth: Not required — caller identity comes from QREXEC_REMOTE_DOMAIN as qubes:<source-vm>.
3.1 Shell one-liner
# Get public key for nostr_index 0
printf '\x00\x00\x00\x3f'"$(echo '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}')" | \
qrexec-client-vm nostr_signer qubes.NsignerRpc | tail -c +5
3.2 Python (stdlib only)
import json, struct, subprocess
def call_nsigner(target_qube, request):
"""Send one framed JSON-RPC request via qrexec, return parsed response."""
payload = json.dumps(request, separators=(",", ":")).encode("utf-8")
frame = struct.pack(">I", len(payload)) + payload
proc = subprocess.Popen(
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
out, err = proc.communicate(frame)
if proc.returncode != 0:
raise RuntimeError(f"qrexec failed: {err.decode()}")
length = struct.unpack(">I", out[:4])[0]
return json.loads(out[4:4+length])
Full demo: client/demo_python.py
3.3 Node.js
const { spawn } = require("child_process");
function callNsigner(targetQube, request) {
return new Promise((resolve, reject) => {
const payload = Buffer.from(JSON.stringify(request), "utf8");
const header = Buffer.alloc(4);
header.writeUInt32BE(payload.length, 0);
const framed = Buffer.concat([header, payload]);
const proc = spawn("qrexec-client-vm", [targetQube, "qubes.NsignerRpc"], {
stdio: ["pipe", "pipe", "pipe"],
});
const chunks = [];
proc.stdout.on("data", (c) => chunks.push(c));
proc.on("close", (code) => {
if (code !== 0) return reject(new Error(`exit code ${code}`));
const buf = Buffer.concat(chunks);
const len = buf.readUInt32BE(0);
resolve(JSON.parse(buf.subarray(4, 4 + len).toString()));
});
proc.stdin.write(framed);
proc.stdin.end();
});
}
Full demo: client/demo_javascript.js
3.4 C (using nostr_core_lib)
#include "nostr_signer.h"
nostr_signer_t *signer = nostr_signer_nsigner_qrexec("nostr_signer", "qubes.NsignerRpc", NULL, 30000);
nostr_signer_nsigner_set_nostr_index(signer, 0);
char pubkey[65];
nostr_signer_get_public_key(signer, pubkey);
Full demo: client/demo_c99.c
3.5 Prerequisites
- n_signer running in the signer qube:
nsigner --listen unix --socket-name nsigner --bridge-source-trusted - qrexec service installed at
/etc/qubes-rpc/qubes.NsignerRpc:exec nsigner bridge --to nsigner - dom0 policy allowing caller qube → signer qube (see
packaging/qubes/policy.d/40-nsigner.policy)
4. Transport: Local Unix abstract socket
Best for: Agents on the same machine as the signer.
Auth: Not required — identity from SO_PEERCRED (kernel-verified UID/PID).
4.1 Discovery
nsigner list
# Output: @nsigner, @nsigner_hairy_dog, etc.
4.2 Using the nsigner CLI
nsigner client '{"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}' --socket-name nsigner
4.3 C (using nostr_core_lib)
#include "nsigner_transport.h"
#include "nsigner_client.h"
nsigner_transport_t *t = nsigner_transport_open_unix("nsigner", 5000);
nsigner_client_t *c = nsigner_client_new(t);
cJSON *params = cJSON_CreateArray();
cJSON *result = NULL;
nsigner_client_call(c, "nostr_get_public_key", params, &result);
See examples/get_public_key_client.c.
4.4 Python (raw socket)
import json, socket, struct
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect("\0nsigner") # \0 prefix for abstract namespace
payload = json.dumps({"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}).encode()
sock.sendall(struct.pack(">I", len(payload)) + payload)
hdr = sock.recv(4)
length = struct.unpack(">I", hdr)[0]
body = sock.recv(length)
print(json.loads(body))
5. Transport: TCP (FIPS mesh)
Best for: Cross-qube or network callers where qrexec is not available.
Auth: Required — kind-27235 auth envelope (see §9).
5.1 Python (with coincurve)
import hashlib, json, socket, struct, time
from coincurve import PrivateKey
HOST, PORT = "192.168.1.100", 11111
CALLER_PRIVKEY = bytes(range(1, 33)) # Replace with your key
params = [{"nostr_index": 0}]
body_hash = hashlib.sha256(json.dumps(params, separators=(",",":")).encode()).hexdigest()
sk = PrivateKey(CALLER_PRIVKEY)
pubkey_x = sk.public_key.format(compressed=False)[1:33].hex()
created_at = int(time.time())
tags = [["nsigner_rpc","1"],["nsigner_method","get_public_key"],["nsigner_body_hash",body_hash]]
serialized = json.dumps([0, pubkey_x, created_at, 27235, tags, "tcp-agent"], separators=(",",":")).encode()
event_id = hashlib.sha256(serialized).hexdigest()
sig = sk.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00"*32).hex()
request = {
"id": "1", "method": "get_public_key", "params": params,
"auth": {"id": event_id, "pubkey": pubkey_x, "created_at": created_at,
"kind": 27235, "tags": tags, "content": "tcp-agent", "sig": sig},
}
payload = json.dumps(request, separators=(",",":")).encode()
frame = struct.pack(">I", len(payload)) + payload
with socket.create_connection((HOST, PORT), timeout=10) as s:
s.sendall(frame)
hdr = s.recv(4)
ln = struct.unpack(">I", hdr)[0]
body = s.recv(ln)
print(json.loads(body))
See examples/get_pubkey_fips.py and examples/n_signer_qube_example_fips.js.
5.2 C (using nostr_core_lib)
nsigner_transport_t *t = nsigner_transport_open_tcp("192.168.1.100", 11111, 10000);
nsigner_client_t *c = nsigner_client_new(t);
nsigner_client_set_auth(c, caller_privkey, "tcp-agent");
// ... call as usual
See examples/get_pubkey_tcp.c.
6. Transport: HTTP
Best for: curl-friendly callers, REST clients.
Auth: Required — kind-27235 auth envelope in the JSON body.
curl -X POST http://192.168.1.100:11112/ \
-H "Content-Type: application/json" \
-d '{
"id":"1",
"method":"get_public_key",
"params":[{"nostr_index":0}],
"auth":{...}
}'
The HTTP listener uses the same framing internally but presents a standard HTTP interface. One request per connection.
7. Transport: USB/serial (hardware signers)
Best for: Embedded/air-gap signers (Feather S3, Teensy 4.1, CYD ESP32).
Auth: Not required — physical possession is the trust anchor.
7.1 Python (pyserial)
import json, struct, serial
ser = serial.Serial("/dev/ttyACM0", 115200, timeout=5)
payload = json.dumps({"id":"1","method":"get_public_key","params":[{"nostr_index":0}]}).encode()
ser.write(struct.pack(">I", len(payload)) + payload)
hdr = ser.read(4)
length = struct.unpack(">I", hdr)[0]
body = ser.read(length)
print(json.loads(body))
See examples/feather_get_public_key.py and examples/feather_sign_event.py.
8. Transport: Stdio (one-shot)
Best for: Scripted one-off calls via pipe.
The signer can be started with --listen stdio to handle exactly one framed request on stdin and write one framed response to stdout.
echo -n '<framed request>' | nsigner --listen stdio --allow-all
Or via the qrexec bridge service (which uses stdio internally to relay to the persistent signer's Unix socket).
9. Auth envelope (kind-27235)
TCP and HTTP listeners require a kind-27235 Nostr event in the auth field of every request. This proves the caller controls a keypair.
9.1 Auth envelope structure
{
"auth": {
"id": "<sha256 of serialized event>",
"pubkey": "<caller's secp256k1 x-only pubkey hex>",
"created_at": <unix timestamp>,
"kind": 27235,
"tags": [
["nsigner_rpc", "<request id>"],
["nsigner_method", "<method name>"],
["nsigner_body_hash", "<sha256 of canonical params JSON>"]
],
"content": "<arbitrary string>",
"sig": "<schnorr signature hex>"
}
}
9.2 Building the auth envelope (pseudocode)
1. Compute body_hash = SHA256(JSON.stringify(params, separators=(",",":")))
2. Build tags: [["nsigner_rpc", id], ["nsigner_method", method], ["nsigner_body_hash", body_hash]]
3. Serialize event for signing: JSON.stringify([0, pubkey, created_at, 27235, tags, content])
4. Compute event_id = SHA256(serialized)
5. Sign event_id with caller's secp256k1 key (Schnorr/BIP-340)
6. Include full event as the "auth" field in the request
9.3 Validation rules (server-side)
| Check | Error code |
|---|---|
Missing auth field |
2014 auth_envelope_required |
| Malformed auth JSON | 2010 auth_envelope_malformed |
kind != 27235 |
2013 auth_kind_invalid |
| Signature doesn't verify | 2012 auth_signature_invalid |
nsigner_rpc tag != request id |
2011 auth_body_mismatch |
nsigner_method tag != request method |
2011 auth_body_mismatch |
nsigner_body_hash != SHA256(params) |
2011 auth_body_mismatch |
| Timestamp skew > 300 seconds | 2015 auth_envelope_mismatch |
| Replay (same event_id seen before) | 2015 auth_envelope_mismatch |
9.4 Python example (full)
See examples/get_pubkey_fips.py for a complete working example.
9.5 JavaScript example (full)
See examples/n_signer_qube_example_fips.js for a complete working example.
10. Complete verb reference
10.1 Algorithm-based verbs
These select a key by algorithm + index (see §11 for derivation paths).
| Verb | Algorithms | Params | Options |
|---|---|---|---|
get_public_key |
All key-deriving | [] |
algorithm, index |
sign |
secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | [<message_hex>] |
algorithm, index, scheme* |
verify |
secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | [<message_hex>, <sig_hex>] |
algorithm, index, scheme* |
encapsulate |
ml-kem-768 | [<peer_pubkey_hex>] |
algorithm |
decapsulate |
ml-kem-768 | [<ciphertext_hex>] |
algorithm, index |
derive_shared_secret |
x25519 | [<peer_pubkey_hex>] |
algorithm, index |
derive |
secp256k1 | [<data>] |
algorithm, index (required) |
encrypt |
otp | [<plaintext_base64>] |
algorithm, encoding |
decrypt |
otp | [<ciphertext>] |
algorithm, encoding |
* scheme: "schnorr" (default) or "ecdsa" for secp256k1.
10.2 Nostr protocol verbs
These select a secp256k1 NIP-06 key via nostr_index (or role/role_path).
| Verb | Params | Options |
|---|---|---|
nostr_get_public_key |
[] |
nostr_index, format |
nostr_sign_event |
[<event_json>] |
nostr_index |
nostr_mine_event |
[<event_json>] |
nostr_index, difficulty, timeout_sec, threads |
nostr_nip04_encrypt |
[<peer_pubkey_hex>, <plaintext>] |
nostr_index |
nostr_nip04_decrypt |
[<peer_pubkey_hex>, <ciphertext>] |
nostr_index |
nostr_nip44_encrypt |
[<peer_pubkey_hex>, <plaintext>] |
nostr_index |
nostr_nip44_decrypt |
[<peer_pubkey_hex>, <ciphertext>] |
nostr_index |
10.3 Metadata
| Verb | Params | Description |
|---|---|---|
get_info |
[] |
Returns signer metadata (name, version, supported verbs/algorithms). Safe to call before mnemonic is loaded. |
10.4 Example requests
// Get public key for ML-DSA-65 index 0
{"id":"1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}
// Sign "hello" with ed25519 index 0
{"id":"2","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}
// Sign a Nostr event with nostr_index 0
{"id":"3","method":"nostr_sign_event","params":["{\"kind\":1,\"content\":\"Hello\",\"tags\":[],\"created_at\":1700000000,\"pubkey\":\"<hex>\"}",{"nostr_index":0}]}
// NIP-44 encrypt
{"id":"4","method":"nostr_nip44_encrypt","params":["<peer_pubkey_hex>","secret message",{"nostr_index":0}]}
// ML-KEM-768 encapsulate
{"id":"5","method":"encapsulate","params":["<peer_pubkey_hex>",{"algorithm":"ml-kem-768"}]}
// Derive HMAC-SHA256(privkey, data) for opaque identifiers
{"id":"6","method":"derive","params":["<data_hex>",{"algorithm":"secp256k1","index":0}]}
11. Algorithm reference
| Algorithm | Key type | FIPS | Derivation path | Pubkey | Privkey | Signature |
|---|---|---|---|---|---|---|
secp256k1 |
Signature (Nostr) | — | m/44'/1237'/<n>'/0/0 |
32 B | 32 B | 64 B |
ed25519 |
Signature (SSH) | — | m/44'/102001'/<n>'/0/0' |
32 B | 32 B | 64 B |
x25519 |
Key agreement (age) | — | m/44'/102002'/<n>'/0/0' |
32 B | 32 B | — |
ml-dsa-65 |
PQ signature | FIPS 204 | DRBG from seed | 1952 B | 4032 B | 3309 B |
slh-dsa-128s |
PQ hash-based sig | FIPS 205 | DRBG from seed | 32 B | 64 B | 7856 B |
ml-kem-768 |
PQ KEM | FIPS 203 | DRBG from seed | 1184 B | 2400 B | — |
otp |
One-time pad | — | USB pad (no derivation) | — | — | — |
11.1 get_public_key response format
Algorithm-based get_public_key returns structured JSON:
{"algorithm":"ml-dsa-65","public_key":"<hex>","key_id":"<first 16 hex chars of pubkey>"}
Nostr nostr_get_public_key returns a plain 64-hex-char string by default, or structured JSON with {"format":"structured"}.
12. Error codes
| Code | Message | Meaning |
|---|---|---|
| -32700 | parse_error |
Request is not valid JSON |
| -32600 | invalid_request |
Missing id, method, or params |
| -32601 | method_not_found |
Unknown verb |
| -32602 | invalid_params |
Malformed arguments |
| 1001 | ambiguous_role_selector |
Multiple role selectors given |
| 1002 | unknown_role |
No role matched selector |
| 1003 | no_default_role |
No selector and no main role |
| 1004 | purpose_mismatch |
Role purpose not valid for verb |
| 1005 | curve_mismatch |
Role curve not valid for verb |
| 1006 | mnemonic_not_loaded |
No mnemonic loaded |
| 1007 | no_termination_condition |
nostr_mine_event without difficulty/timeout |
| 1008 | mining_failed |
Internal PoW error |
| 1009 | not_yet_implemented |
Verb+algorithm not yet implemented |
| 1010 | algorithm_not_supported_for_verb |
Algorithm not valid for verb |
| 2010 | auth_envelope_malformed |
Auth JSON is malformed |
| 2011 | auth_body_mismatch |
Auth tags don't match request |
| 2012 | auth_signature_invalid |
Auth signature doesn't verify |
| 2013 | auth_kind_invalid |
Auth kind != 27235 |
| 2014 | auth_envelope_required |
Auth missing (TCP/HTTP) |
| 2015 | auth_envelope_mismatch |
Timestamp skew or replay |
13. End-to-end: publish a Nostr event
This is the most common agent task. Here's the complete flow using qrexec (simplest cross-qube transport):
#!/usr/bin/env python3
"""Publish a Nostr event via n_signer over qrexec."""
import json, struct, subprocess, sys, time
def call_nsigner(target_qube, request):
payload = json.dumps(request, separators=(",", ":")).encode()
frame = struct.pack(">I", len(payload)) + payload
proc = subprocess.Popen(
["qrexec-client-vm", target_qube, "qubes.NsignerRpc"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
out, _ = proc.communicate(frame)
length = struct.unpack(">I", out[:4])[0]
return json.loads(out[4:4+length])
target = sys.argv[1] if len(sys.argv) > 1 else "nostr_signer"
idx = int(sys.argv[2]) if len(sys.argv) > 2 else 0
# Step 1: Get public key
pub = call_nsigner(target, {"id":"1","method":"get_public_key","params":[{"nostr_index":idx}]})["result"]
print(f"Pubkey: {pub}")
# Step 2: Build and sign event
event = {
"kind": 1,
"content": "Hello from my agent!",
"created_at": int(time.time()),
"tags": [],
"pubkey": pub,
}
result = call_nsigner(target, {
"id": "2",
"method": "nostr_sign_event",
"params": [json.dumps(event, separators=(",",":")), {"nostr_index": idx}],
})
signed = json.loads(result["result"])
print(f"Event ID: {signed['id']}")
print(f"Signature: {signed['sig']}")
# Step 3: Broadcast to relay(s)
# signed is a complete Nostr event with id and sig — send to any relay
14. End-to-end: sign arbitrary data
Using algorithm-based verbs (any algorithm, any index):
# Sign "hello" with ed25519 index 0
result = call_nsigner(target, {
"id": "1",
"method": "sign",
"params": ["68656c6c6f", {"algorithm": "ed25519", "index": 0}],
})
signature = result["result"] # hex string
# Verify
result = call_nsigner(target, {
"id": "2",
"method": "verify",
"params": ["68656c6c6f", signature, {"algorithm": "ed25519", "index": 0}],
})
print(f"Verified: {result['result']}") # "true" or "false"
15. End-to-end: PQ KEM encapsulate/decapsulate
# Get ML-KEM-768 public key for index 0
pub_result = call_nsigner(target, {
"id": "1",
"method": "get_public_key",
"params": [{"algorithm": "ml-kem-768", "index": 0}],
})
pub_info = json.loads(pub_result["result"])
print(f"ML-KEM-768 pubkey: {pub_info['public_key'][:32]}...")
# Encapsulate (generate a shared secret + ciphertext for that pubkey)
enc_result = call_nsigner(target, {
"id": "2",
"method": "encapsulate",
"params": [pub_info["public_key"], {"algorithm": "ml-kem-768"}],
})
enc_data = json.loads(enc_result["result"])
print(f"Ciphertext: {enc_data['ciphertext'][:32]}...")
print(f"Shared secret: {enc_data['shared_secret'][:32]}...")
# Decapsulate (recover shared secret from ciphertext using private key)
dec_result = call_nsigner(target, {
"id": "3",
"method": "decapsulate",
"params": [enc_data["ciphertext"], {"algorithm": "ml-kem-768", "index": 0}],
})
dec_data = json.loads(dec_result["result"])
print(f"Decapsulated secret: {dec_data['shared_secret'][:32]}...")
assert dec_data["shared_secret"] == enc_data["shared_secret"]
print("✓ KEM round-trip verified")
16. Discovery: finding running signers
16.1 List running signers
nsigner list
Output (one per line):
@nsigner
@nsigner_hairy_dog
@nsigner_brave_canyon
16.2 Check if a specific signer is running
nsigner list | grep -q @nsigner && echo "running" || echo "not running"
16.3 Programmatic discovery (Python)
import subprocess
result = subprocess.run(["nsigner", "list"], capture_output=True, text=True)
signers = [s.strip() for s in result.stdout.split("\n") if s.strip()]
print(f"Found {len(signers)} signer(s): {signers}")
17. Prerequisites and setup
17.1 On the signer qube
# Install nsigner
# Start the persistent signer
nsigner --listen unix --socket-name nsigner --bridge-source-trusted
# Install qrexec service
sudo cp packaging/qubes/rpc/qubes.NsignerRpc /etc/qubes-rpc/qubes.NsignerRpc
sudo chmod 0755 /etc/qubes-rpc/qubes.NsignerRpc
17.2 In dom0
# Install policy
sudo cp packaging/qubes/policy.d/40-nsigner.policy /etc/qubes/policy.d/40-nsigner.policy
# Tag the signer qube
qvm-tags nostr_signer add nsigner-signer
17.3 On the caller qube
No special setup needed — just qrexec-client-vm (pre-installed in all Qubes templates).
18. Reference files in this repo
| File | What it shows |
|---|---|
client/demo_python.py |
Full Python demo (qrexec, stdlib only) — get_public_key, sign_event, nip44, mine_event |
client/demo_javascript.js |
Full Node.js demo (qrexec) — same operations |
client/demo_c99.c |
Full C99 demo (qrexec, nostr_core_lib) — same operations |
examples/get_pubkey_fips.py |
Minimal TCP/FIPS with auth envelope (Python) |
examples/get_pubkey_qrexec.c |
Minimal qrexec in C |
examples/get_pubkey_tcp.c |
Minimal TCP in C with auth envelope |
examples/n_signer_qube_example_fips.js |
TCP/FIPS in Node.js with auth envelope |
examples/n_signer_qube_example_qrexec.js |
qrexec in Node.js |
examples/get_public_key_client.c |
Unix socket in C |
examples/sign_event_client.c |
Unix socket sign event in C |
examples/feather_get_public_key.py |
USB/serial hardware signer (Python) |
examples/feather_sign_event.py |
USB/serial sign event (Python) |
examples/pq_sign_example.c |
ML-DSA-65 sign in C |
examples/pq_kem_example.c |
ML-KEM-768 encaps/decaps in C |
examples/ssh_sign_example.c |
ed25519 SSH sign in C |
documents/CLIENT_IMPLEMENTATION.md |
Full wire contract spec (733 lines) |
README.md |
Full n_signer documentation (741 lines) |
packaging/qubes/setup_signer_qube.sh |
Automated signer qube setup |
.roo/n_signer_client.md |
Roo skill file (agent-invocable) |