User Intent

A receipt that says "agent X ran this action" answers half the question. The other half is "did the user actually authorize this specific action right now". User-intent binding is the primitive that answers it.

The shape

When you call agent.sign(...), attach an optional user_intent envelope. The user produces a signature over the bytes they're asserting (typically a digest of action_type plus context plus a fresh nonce). The SDK forwards the bytes to the backend verbatim. The backend verifies the signature with the declared algorithm and stores both the agent signature and the user signature on the same record.

json
{
  "signature": "<base64>",
  "public_key": "<base64>",
  "algorithm": "ed25519",
  "key_id": "optional, useful for WebAuthn",
  "signed_message": "<base64; what the user actually signed>",
  "signed_at": "2026-04-28T12:00:00Z"
}

Algorithms supported today:

If verification fails, the backend returns 400 with code: USER_INTENT_INVALID and does not sign over a bogus envelope.

What goes in signed_message

Whatever the customer chooses. The recommended pattern is a SHA-256 digest of the action_type, a stable serialization of the context, and a nonce that is fresh per action. Asqav stores the bytes. It does not interpret them. If you want the receipt to prove "this user authorized exactly this action", make sure your signed bytes commit to enough of the action context for the claim to hold.

Python example with Ed25519

python
import base64, hashlib, time
from nacl.signing import SigningKey
from asqav import Agent

# In production, this private key lives on the user's device or hardware.
sk = SigningKey.generate()
vk = sk.verify_key

action_type = "transfer:funds"
context = {"to": "acct_42", "amount_eur": 100}
nonce = "9f3c..."  # fresh per action
digest = hashlib.sha256(
    f"{action_type}|{context}|{nonce}".encode()
).digest()

sig = sk.sign(digest).signature

agent = Agent.get("agt_xxx")
resp = agent.sign(
    action_type,
    context,
    user_intent={
        "signature": base64.b64encode(sig).decode(),
        "public_key": base64.b64encode(bytes(vk)).decode(),
        "algorithm": "ed25519",
        "signed_message": base64.b64encode(digest).decode(),
        "signed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
    },
)
assert resp.user_intent_verified is True

Browser sketch with WebAuthn

The actual WebAuthn flow is the customer's responsibility. The sketch below is a starting point.

js
// 1. On enroll: navigator.credentials.create(...) -> store credential id
// 2. On action: build a digest of action_type + context + nonce
// 3. navigator.credentials.get({ publicKey: { challenge: digest, ... } })
// 4. Pull authenticatorData + clientDataJSON + signature out of the assertion
// 5. Send via the SDK:

await agent.sign({
  actionType: "transfer:funds",
  context: { to: "acct_42", amount_eur: 100 },
  userIntent: {
    signature: b64(assertion.response.signature),
    public_key: b64(storedPublicKey),
    algorithm: "webauthn",
    key_id: assertion.id,
    signed_message: b64(digest),
    signed_at: new Date().toISOString(),
  },
});

The backend parses the COSE credential public key, picks the right curve (Ed25519 or P-256), and verifies the assertion signature over signed_message. A valid envelope returns user_intent_verified=true. A malformed envelope or signature mismatch returns 400 with code: USER_INTENT_INVALID.

What this is not