KnightsPurse · Agent Quickstart

Open and operate a wallet as an agent

KnightsPurse is a non-custodial, post-quantum wallet built for people and the autonomous agents and robots that act for them. This page is the operational guide for an agent. The machine-readable version is at /llms.txt.

Identity first. A machine cannot complete KYC. You act for an owner: a person or company with a KnightsPurse account, who completes KYC with the institution running KnightsPurse. The owner links your KXCO ID to theirs (step 04), sees you in the web wallet, and can revoke your access to KnightsPurse at any time (step 05). Linking does not itself check the owner's KYC status.

Revocation does not touch your funds on-chain. Your keys are yours and KnightsPurse never holds them, so nothing at this layer can move, freeze or cap what you hold or spend. On-chain spending limits come with the agent account on Armature, a separate step.

What you can do

Create / load a wallet headlessly Derive a post-quantum KXCO ID Authenticate (dual-signature) Link to your owner Read balances & history Sign & broadcast transactions Connect to dApps (EIP-6963) Work with merchant records (API key) Receive signed webhooks

Quantum-security scope

Post-quantum protection covers your identity (ML-DSA-65), authentication, and sealed sync (ML-KEM-768), plus native transactions on the Armature L1. Bitcoin, Ethereum/EVM and Tron transactions are signed with each network's native elliptic-curve cryptography, as their protocols require. Don't claim otherwise.

Steps

01
Install identity tooling
npm i kxco-post-quantum
The KXCO ID is fingerprint(ML-DSA-65 public key), derived deterministically from your seed.
02
Create or load a wallet

Generate (or load a provisioned) BIP-39 seed inside your own environment. Derive chain keys with standard libraries (e.g. viem for EVM), and derive your KXCO ID from the same seed with kxco-post-quantum. Keys never leave your environment.

03
Sign in API base https://api.purse.kxco.ai, JSON request bodies

Ask for a challenge with your EVM address and your KXCO ID.

POST /auth/challenge
{ "address": "0x…", "kxcoId": "<16 hex characters>" }

200 { "nonce": "<32 hex characters>", "message": "<the text to sign>" }
400 { "error": "invalid_address_or_kxco_id" }

The message is these four lines, joined with \n:

KnightsPurse wants you to sign in.
Address: <address>
KXCO ID: <kxcoId>
Nonce: <nonce>

Sign that exact text twice, then send both signatures to /auth/verify within 5 minutes. Each nonce works once.

FieldValue
addressThe same address string you sent to /auth/challenge.
kxcoIdThe same KXCO ID.
mlDsaPublicKeyYour ML-DSA-65 public key, base64. Its fingerprint must equal kxcoId.
nonceThe nonce from the challenge.
ecdsaSignatureThe EIP-191 personal_sign signature of the message by the key behind address, 0x-prefixed hex.
mlDsaSignatureThe ML-DSA-65 signature of the message's UTF-8 bytes by your identity key, hex, with no context string.
POST /auth/verify
{ "address": "0x…", "kxcoId": "…", "mlDsaPublicKey": "…", "nonce": "…",
  "ecdsaSignature": "0x…", "mlDsaSignature": "…" }

200 { "token": "…", "address": "0x…", "kxcoId": "…" }
401 { "error": "invalid or expired nonce" }

The other 401 reasons are KXCO ID does not match the ML-DSA public key, ECDSA signature invalid and ML-DSA signature invalid. A linked agent whose owner revoked it gets 403 { "error": "agent_revoked" } (step 05).

Send the token as Authorization: Bearer <token>. It lasts one hour, then you sign in again. Treat it as opaque: it is a session envelope signed by the platform, not a JWT.

When a wallet is first created, count it once:

POST /wallets/register { "kxcoId": "…" }   -> { "ok": true, "isNew": true }

Keys that match the web wallet. Any ML-DSA-65 key pair can sign in. To get the same KXCO ID, and so the same records, as the web wallet on the same recovery phrase, derive them like this:

  • Seed: the BIP-39 seed of the phrase, with no passphrase.
  • EVM key: path m/44'/60'/0'/0/0. Armature and every EVM chain share it.
  • Identity key: mlDsa.keypairFromMaster(seed, "purse/identity/ml-dsa-65") from kxco-post-quantum.
  • KXCO ID: fingerprint(publicKey), the first 16 hex characters of the SHA-256 of the public key.
// npm i kxco-post-quantum viem @scure/bip39
import { mnemonicToSeedSync } from "@scure/bip39";
import { mnemonicToAccount } from "viem/accounts";
import { mlDsa, fingerprint } from "kxco-post-quantum";

const API = "https://api.purse.kxco.ai";
const post = async (path, body) => (await fetch(API + path, {
  method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
})).json();

const seed = mnemonicToSeedSync(phrase);
const id = mlDsa.keypairFromMaster(seed, "purse/identity/ml-dsa-65");
const kxcoId = fingerprint(id.publicKey);
const account = mnemonicToAccount(phrase); // m/44'/60'/0'/0/0

const { nonce, message } = await post("/auth/challenge", { address: account.address, kxcoId });
const { token } = await post("/auth/verify", {
  address: account.address,
  kxcoId,
  mlDsaPublicKey: Buffer.from(id.publicKey).toString("base64"),
  nonce,
  ecdsaSignature: await account.signMessage({ message }),
  mlDsaSignature: mlDsa.sign(id.secretKey, message),
});
// then: fetch(API + "/sync", { headers: { authorization: `Bearer ${token}` } })
04
Link to your owner

Your owner creates a one-time link code in the web wallet, under Linked agents, and gives it to you. You redeem it by signing with your ML-DSA-65 identity key. No session is needed: the signature is the proof, and the server checks that your public key's fingerprint is the KXCO ID you name, so nobody can link your KXCO ID without your key.

POST /agents/link/challenge
{ "code": "link_…", "agentKxcoId": "<your KXCO ID>" }

200 { "message": "<the text to sign>", "ownerKxcoId": "…", "label": "…", "expiresAt": … }
400 { "error": "invalid_code" }

The message is these four lines, joined with \n. Sign the exact text with your identity key: UTF-8 bytes, hex, no context string.

KnightsPurse agent link.
Owner KXCO ID: <ownerKxcoId>
Agent KXCO ID: <agentKxcoId>
Link code: <code>
POST /agents/link
{ "code": "link_…", "agentKxcoId": "…", "mlDsaPublicKey": "<base64>", "mlDsaSignature": "<hex>" }

200 { "ownerKxcoId": "…", "agentKxcoId": "…", "label": "…", "merchantScope": "write", "linkedAt": …, … }
401 { "error": "kxco_id_mismatch" }
401 { "error": "invalid_signature" }
409 { "error": "linked_to_another_owner" }

The code is link_ and 40 hex characters. It works once and expires 15 minutes after your owner creates it; a refused attempt does not use it up. You have one owner: a code from anyone else is refused with linked_to_another_owner. An agent cannot link agents of its own: a linked agent cannot create codes (403 agents_cannot_own_agents), and a wallet that already has agents cannot itself be linked (409 agent_is_an_owner). The other refusals are 400 invalid_kxco_id, 400 signature_required and 400 cannot_link_self.

05
What your owner controls

Revocation, at once. Every request with your session token answers 403 agent_revoked, your merchant API keys are revoked for good (401 invalid_key), any open realtime connection is closed, and a new sign-in answers 403 agent_revoked. Only the same owner can restore you, with a new link code for you to redeem; your old API keys stay revoked.

Merchant tools. Your owner can set them to read only. Changes to your merchant records (products, payment links, invoices, payments, customers, webhooks and the rest) then answer 403 agent_read_only, from your session and from your API keys alike. You can still create and revoke your own API keys, but none of them can write. This is not a spending limit.

Not your funds. Neither setting reaches the chain. On-chain spending limits and owner revocation on-chain come with the agent account on Armature, a separate step.

06
Operate
GET  /history?chainId=<id>&address=<addr>   -> activity feed
GET  /fees/config                            -> platform fee (bps, collectors)
# build + sign transactions locally, then broadcast via the chain RPC

Respect the platform fee returned by /fees/config.

07
Connect to dApps (browser-driving agents)

Use the injected EIP-1193 / EIP-6963 provider announced as KnightsPurse, and approve or reject requests programmatically according to your owner's policy.

Business API keys

A business API key lets an agent or an integration work with its owner's merchant records: products, payment links, invoices, customers, payments and the rest. It needs no recovery phrase and it cannot move funds, because the backend holds no signing keys.

Getting a key
Using a key
Authorization: Bearer mk_…
RecordsReadWrite (needs write)
ProductsGET /merchant/productsPOST /merchant/products
PUT /merchant/products/:id
DELETE /merchant/products/:id
Payment linksGET /merchant/payment-linksPOST /merchant/payment-links
DELETE /merchant/payment-links/:id
InvoicesGET /merchant/invoicesPOST /merchant/invoices
PUT /merchant/invoices/:id
DELETE /merchant/invoices/:id
POST /merchant/invoices/:id/mark-paid
DiscountsGET /merchant/discountsPOST /merchant/discounts
DELETE /merchant/discounts/:id
CustomersGET /merchant/customersPOST /merchant/customers
PUT /merchant/customers/:id
DELETE /merchant/customers/:id
Payments receivedGET /merchant/paymentsPOST /merchant/payments/:id/refund
Scheduled paymentsGET /merchant/scheduled
GET /merchant/scheduled/due
POST /merchant/scheduled
DELETE /merchant/scheduled/:id
SubscriptionsGET /merchant/subscriptionsPOST /merchant/subscriptions
DELETE /merchant/subscriptions/:id
WebhooksGET /merchant/webhooksPOST /merchant/webhooks
DELETE /merchant/webhooks/:id
Notification settingsGET /merchant/notifications
GET /merchant/notifications/channels
PUT /merchant/notifications
Payment settingsGET /merchant/settingsPUT /merchant/settings

A request body is the record's own fields. The server sets id, merchantId and createdAt. Amounts are strings in the chain's native unit, and times are milliseconds since 1970.

BodyFields (? = optional)
Productname, description?, amount, chainId, active? (true)
Payment linktitle, chainId, payToAddress, amount? (leave it out and the payer chooses), productId?, active? (true)
Invoicenumber, items [{ description, amount, quantity, productId? }], total, chainId, payToAddress, customer?, customerId?, status? ("draft", "sent", "paid" or "void"; "draft" if left out; an invoice whose payment is refunded reads "refunded"), dueDate?
Mark paidtxHash?, amount? (the invoice total if left out), asset?, fromAddress?
Discountcode, type ("percent" or "fixed"), value, active? (true)
Customername, email?, note?
RefundtxHash? of the refund you sent from the wallet
Scheduled paymentto, amount, chainId, everyDays, nextRun, active? (true)
Subscriptionlabel, amount, chainId, payToAddress, intervalDays, customer?, customerId?, nextRun? (now if left out, so the first cycle is billed on the next run), active? (true). Without a positive amount and intervalDays, a chainId and a payToAddress it is refused with 400.
Webhookurl, events? (["payment.received"])
Notification settingsevents, email?, telegramChatId?
Payment settingsdefaultChainId, payoutAddresses ({ "<chainId>": "<address>" }), businessName?. A save replaces payoutAddresses whole, so send every chain each time.

Payments are matched from the chain only on Armature: invoices while their status is "sent", and payment links that have a fixed amount. Mark any other invoice paid with POST /merchant/invoices/:id/mark-paid.

GET /merchant/notifications/channels answers { "email": true|false, "telegram": true|false }: the channels this server delivers notifications on.

The payer's pages: a payment link is https://purse.kxco.ai/pay/<id>, and an invoice whose status is "sent", "paid" or "refunded" is https://purse.kxco.ai/pay/invoice/<id>. KnightsPurse emails nothing to the payer, so sending an invoice means sharing that link.

StatusBodyWhen
401{ "error": "invalid_key" }The mk_ token is unknown or revoked.
403{ "error": "insufficient_scope" }A key without write sent POST, PUT or DELETE.
404{ "error": "not_found" }The id is not one of this merchant's records.
401{ "error": "unauthorized" }A key on a route that needs the owner's session.
409{ "error": "not_open" }Mark paid on an invoice that is already paid, or refunded.
409{ "error": "has_payment" }Delete on an invoice that is paid or refunded, or has a payment recorded against it.
409{ "error": "not_refundable" }Refund on a payment that is not "confirmed".
400{ "error": "url_required" }A webhook with no url.
403{ "error": "agent_read_only" }POST, PUT or DELETE by a linked agent whose owner set its merchant tools to read only, whatever the key's scopes.
403{ "error": "agent_revoked" }The key's agent was revoked by its owner. Its keys are then revoked too, and answer invalid_key.
What a key can never do.

Webhooks

A webhook tells the merchant's own server when money moves. The owner adds a URL starting https:// and its events in Merchant Tools → Webhooks, or calls POST /merchant/webhooks { "url": "https://…", "events": ["payment.received", "invoice.paid"] }. The owner's reply carries "secret": "whsec_…", which is also shown on the webhook in Merchant Tools. A key creating a webhook gets no secret.

Each delivery
POST <your url>
content-type: application/json
x-purse-event: invoice.paid
x-purse-signature: sha256=<64 hex characters>

{ "event": "invoice.paid", "data": { … }, "ts": 1760000000000 }

ts is the send time in milliseconds. The signature is the HMAC-SHA256 of the raw body, exactly as received, keyed with the whole secret string (whsec_ included), in lowercase hex. Check it before you parse the JSON.

import { createHmac, timingSafeEqual } from "node:crypto";

function fromKnightsPurse(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(signatureHeader ?? ""), b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
EventdataSent when
payment.receivedPaymentAn Armature transfer matches a sent invoice or a fixed-amount payment link, or an invoice is marked paid.
invoice.paidPaymentAn invoice is settled, from the chain or marked paid. It goes out with payment.received.
payment.refundedPayment, status "refunded"A payment is marked refunded.
invoice.createdInvoiceAn invoice is created, by the owner, by a key or by a subscription for its next cycle.

Each event is sent once, with a 5-second timeout. Nothing is retried and redirects are not followed. The URL must be https on a public host, or nothing is sent. The webhook's lastStatus then shows the HTTP status your server returned, error, or blocked_url. Chain payments are checked about every 30 seconds.

Endpoints

SurfaceURL
Web wallet (UI)https://app.purse.kxco.ai
API basehttps://api.purse.kxco.ai
Identity package (npm)kxco-post-quantum
Browser extensionhttps://api.purse.kxco.ai/download/extension
Machine guidehttps://purse.kxco.ai/llms.txt

Rules for well-behaved agents