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.
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
npm i kxco-post-quantumThe KXCO ID is
fingerprint(ML-DSA-65 public key), derived deterministically from your seed.
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.
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.
| Field | Value |
|---|---|
address | The same address string you sent to /auth/challenge. |
kxcoId | The same KXCO ID. |
mlDsaPublicKey | Your ML-DSA-65 public key, base64. Its fingerprint must equal kxcoId. |
nonce | The nonce from the challenge. |
ecdsaSignature | The EIP-191 personal_sign signature of the message by the key behind address, 0x-prefixed hex. |
mlDsaSignature | The 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")fromkxco-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}` } })
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.
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.
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.
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.
- The owner creates one in the web wallet under Merchant Tools → API Keys: a label, the allow write box if the key may change records, then Create key.
- Or, signed in as the owner (step 03), with the session token:
POST /merchant/keys { "label": "Ops agent", "scopes": ["read", "write"] }. The reply is{ "key": { "id", "label", "prefix", "scopes", "createdAt", … }, "token": "mk_…" }.scopesdefaults to["read"]. - The token is
mk_followed by 48 hex characters. It is shown once and only its SHA-256 hash is stored. Keys do not expire. - The owner lists keys with
GET /merchant/keys(never the token) and revokes one withDELETE /merchant/keys/:id. A revoked key is refused at once.
Authorization: Bearer mk_…
read: every GET route in the table below.write: also POST, PUT and DELETE on those routes. A key without it gets 403 on them.
| Records | Read | Write (needs write) |
|---|---|---|
| Products | GET /merchant/products | POST /merchant/products PUT /merchant/products/:id DELETE /merchant/products/:id |
| Payment links | GET /merchant/payment-links | POST /merchant/payment-links DELETE /merchant/payment-links/:id |
| Invoices | GET /merchant/invoices | POST /merchant/invoices PUT /merchant/invoices/:id DELETE /merchant/invoices/:id POST /merchant/invoices/:id/mark-paid |
| Discounts | GET /merchant/discounts | POST /merchant/discounts DELETE /merchant/discounts/:id |
| Customers | GET /merchant/customers | POST /merchant/customers PUT /merchant/customers/:id DELETE /merchant/customers/:id |
| Payments received | GET /merchant/payments | POST /merchant/payments/:id/refund |
| Scheduled payments | GET /merchant/scheduled GET /merchant/scheduled/due | POST /merchant/scheduled DELETE /merchant/scheduled/:id |
| Subscriptions | GET /merchant/subscriptions | POST /merchant/subscriptions DELETE /merchant/subscriptions/:id |
| Webhooks | GET /merchant/webhooks | POST /merchant/webhooks DELETE /merchant/webhooks/:id |
| Notification settings | GET /merchant/notifications GET /merchant/notifications/channels | PUT /merchant/notifications |
| Payment settings | GET /merchant/settings | PUT /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.
| Body | Fields (? = optional) |
|---|---|
| Product | name, description?, amount, chainId, active? (true) |
| Payment link | title, chainId, payToAddress, amount? (leave it out and the payer chooses), productId?, active? (true) |
| Invoice | number, 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 paid | txHash?, amount? (the invoice total if left out), asset?, fromAddress? |
| Discount | code, type ("percent" or "fixed"), value, active? (true) |
| Customer | name, email?, note? |
| Refund | txHash? of the refund you sent from the wallet |
| Scheduled payment | to, amount, chainId, everyDays, nextRun, active? (true) |
| Subscription | label, 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. |
| Webhook | url, events? (["payment.received"]) |
| Notification settings | events, email?, telegramChatId? |
| Payment settings | defaultChainId, 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.
| Status | Body | When |
|---|---|---|
| 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. |
- List, create or revoke keys.
/merchant/keystakes only the owner's session token. - See a webhook signing secret.
GETandPOST /merchant/webhooksleavesecretout when called with a key. The owner sees it in Merchant Tools → Webhooks. - Call routes that need a signed-in session, such as
/sync,/kyc/start,/kyc/statusand/fees/quote. - Move funds or sign anything.
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.
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);
}
| Event | data | Sent when |
|---|---|---|
payment.received | Payment | An Armature transfer matches a sent invoice or a fixed-amount payment link, or an invoice is marked paid. |
invoice.paid | Payment | An invoice is settled, from the chain or marked paid. It goes out with payment.received. |
payment.refunded | Payment, status "refunded" | A payment is marked refunded. |
invoice.created | Invoice | An invoice is created, by the owner, by a key or by a subscription for its next cycle. |
- Payment: id, merchantId, invoiceId?, linkId?, chainId, fromAddress?, toAddress, amount, asset?, txHash?, status ("pending", "confirmed" or "refunded"), source ("reconciled" or "manual"), createdAt, confirmedAt?, refundedAt?, refundTxHash?
- Invoice: id, merchantId, number, customer?, customerId?, items, total, chainId, payToAddress, status, dueDate?, createdAt
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
| Surface | URL |
|---|---|
| Web wallet (UI) | https://app.purse.kxco.ai |
| API base | https://api.purse.kxco.ai |
| Identity package (npm) | kxco-post-quantum |
| Browser extension | https://api.purse.kxco.ai/download/extension |
| Machine guide | https://purse.kxco.ai/llms.txt |
Rules for well-behaved agents
- Keep keys confined to your environment; never transmit a recovery phrase or private key.
- Operate within the limits your owner sets. Every action is attributable to the owner.
- Respect the platform fee and the operating institution's terms.