Skip to main content

paygent-agent-sdk

Signing, KYA, and the MCP envelope for building a Paygent wallet agent — the primitives of the payment-agent contract, packaged so you do not have to vendor a reference implementation to get them right.

The contract itself is Paygent's payment-agent guide, which they give you at onboarding. This package implements it; it does not replace reading it.

This is not the platform's client SDK

Read this first, because the names are close enough to cost you a day.

Your code Direction The package you want
calls Paygent outbound the platform's /v1 client SDK
is called by Paygent inbound this one

This package is for the third party on the far end of the contract: you publish an agent card (§1), verify Paygent's inbound RFC 9421 signatures and sign your own responses (§2), speak §3's JSON-RPC envelope, run §7's KYA checks on a mandate and an approval proof, and answer the §4 tool calls out of your own ledger. The two packages are disjoint in direction and in dependency — neither is a superset of the other, and installing the wrong one gets you an API that faces the wrong way.

A bare § below is a section of that guide. paygent_agent.CONTRACT_VERSION names the contract revision this package implements (1.1), not the version of any agent built on it.

Install

pip install paygent-agent-sdk

Dependencies are cryptography and pydantic, and deliberately nothing else. No FastAPI, no uvicorn, no httpx: the envelope takes and returns bytes and dicts, so the web framework is yours to pick. The constraint is asserted, not just intended: the package's own test suite imports every module in an environment where the frameworks are unimportable.

Build a minimal agent

A conformant agent is two HTTP endpoints — POST /mcp and the card at /.well-known/agent_card.json — and the SDK covers everything between the bytes arriving and the bytes leaving. Four pieces follow: the tools, the endpoint, the card, and the check that you agree with Paygent on the bytes.

The examples are Starlette for brevity; only the request and response objects are framework-specific. Every upper-case name is yours to supply — MCP_URL is the endpoint URL you publish on your card, KEY_ID and PRIVATE_KEY are your response-signing keypair, and PAYGENT_PUBLIC_KEYS is the key id → public key mapping Paygent gives you at onboarding. So is my_ledger: that is your own store, and the SDK never touches it.

The fragments below are excerpts from a complete, working agent. All ten §4 tools, idempotency, the redemption lifecycle, and §4.11's approval flow are built out in examples/minimal/ — roughly 650 lines, on Starlette, scoring 16 of 20 on the §8 harness with the remaining four reported not-implemented rather than failed. Read it when a quickstart snippet stops being enough.

1. The tools you actually implement

Everything the guide says a tool does lives behind ToolRegistry. The envelope never sees your balances, your stores, or your keys — which is what makes it reusable by an agent that shares no code with the reference one.

from paygent_agent.envelope import InvalidToolCall
from paygent_agent.errors import ErrorCode, WalletError


class Wallet:  # satisfies `envelope.ToolRegistry` structurally; no base class to inherit
    def descriptors(self, *, sandbox: bool) -> list[dict]:
        """§3's `tools/list`. Advertise §4.10 if and only if `sandbox` is true."""
        return [
            {
                "name": "wallet.loyalty_balance",
                "description": "Report the user's spendable and reserved loyalty points.",
                "inputSchema": {
                    "type": "object",
                    "properties": {"user_ref": {"type": "string"}},
                    "required": ["user_ref"],
                },
            }
        ]

    def call(self, name, arguments) -> dict:
        """Run one tool. `name` and `arguments` arrive exactly as sent, untyped — validating them
        is your job, not the envelope's."""
        if name != "wallet.loyalty_balance" or not isinstance(arguments, dict):
            # Unanswerable, not a money outcome: this becomes JSON-RPC `-32602`.
            raise InvalidToolCall(name)
        user_ref = arguments.get("user_ref")
        if not isinstance(user_ref, str):
            raise InvalidToolCall(name)
        balance = my_ledger.loyalty_balance(user_ref)
        if balance is None:
            # A §6 domain failure. It leaves as a *result* with `isError: true` and HTTP 200 —
            # never a JSON-RPC error, which §3 reserves for "the peer is unusable".
            raise WalletError(ErrorCode.UNKNOWN_INSTRUMENT)
        # The plain payload. The envelope applies `tool_result` for you on the way out — wrapping
        # it here too would nest one result inside another.
        return {
            "user_ref": user_ref,
            "available_points": balance.available,
            "reserved_points": balance.reserved,
            "currency": balance.currency,
        }

A spend-affecting tool runs §7's mandate check before it touches the ledger:

from paygent_agent.kya import MandatedCall, verify_mandate

mandate = verify_mandate(
    arguments.get("mandate"),
    MandatedCall(user_ref=user_ref, points=points),
)

A refusal raises KyaRefusal, which is a WalletError carrying mandate_invalid or approval_invalid. Any handler that already turns WalletError into a §6 result needs no new branch; the reason attribute is the fine-grained detail, and it stays on your side of the wire — §6 allows only the code itself out.

2. The endpoint

import anyio.to_thread
from starlette.responses import Response

from paygent_agent.envelope import MAX_REQUEST_BODY_BYTES, MCPEnvelope, read_capped_body
from paygent_agent.signing import SignatureError, sign_response, verify_request

envelope = MCPEnvelope(Wallet(), server_name="acme-wallet", sandbox=False)


def signed(answer):
    """Sign the exact bytes the envelope produced, and send them untouched.

    Never re-encode a body after signing: the digest covers the bytes as they were, and a
    re-serialisation is a response whose digest covers something else.
    """
    headers = sign_response(
        status=answer.status, body=answer.body, key_id=KEY_ID, private_key=PRIVATE_KEY
    )
    return Response(
        answer.body,
        status_code=answer.status,
        media_type="application/json" if answer.is_json else None,
        headers=headers,
    )


async def mcp(request):
    body = await read_capped_body(request.stream())
    if body is None:
        # Over `MAX_REQUEST_BODY_BYTES` (1 MiB), so it can never be verified. `unauthenticated`
        # like any other unverifiable request — a distinct code would only tell an attacker where
        # the cap sits.
        return signed(envelope.unauthenticated())

    try:
        verify_request(
            method=request.method,
            # Your card's `url`, not a reconstructed request URI. Behind a TLS-terminating proxy
            # the two differ, and only the published value is what Paygent could have signed.
            target_uri=MCP_URL,
            body=body,
            headers={k.lower(): v for k, v in request.headers.items()},
            known_keys=PAYGENT_PUBLIC_KEYS,
        )
    except SignatureError:
        # §2: 401 with a *signed* body carrying `unauthenticated`. The reason stays in your logs —
        # only the §6 code reaches the wire.
        return signed(envelope.unauthenticated())

    # Off the event loop: `dispatch` is blocking, and a registry that fsyncs a durable record
    # would otherwise stall every other request in flight for the length of each call.
    return signed(await anyio.to_thread.run_sync(envelope.dispatch, body))

Three things the envelope deliberately leaves to you:

  • Verify before dispatching. dispatch trusts its input; §2 verification is the gate in front of it.
  • Sign everything you return, the 401s and the 500 included. Paygent verifies your responses, so an unsigned rejection is indistinguishable from an attacker's.
  • Keep dispatch off the event loop. It is blocking by contract, so a registry that touches a durable store stalls everything else if it runs on the loop. The snippet uses anyio.to_thread.run_sync; outside ASGI, any worker thread will do. Once calls run in threads, your registry needs its own lock, as examples/minimal/ shows.

3. The card

Served at /.well-known/agent_card.json, and unsigned: it is the trust root, and its authenticity rests on TLS to your origin (§7.4).

from paygent_agent.card import AgentCardSpec, build_agent_card
from paygent_agent.keys import public_key_to_base64

card = build_agent_card(
    AgentCardSpec(
        name="acme-wallet",
        url=MCP_URL,
        signing_keys={KEY_ID: public_key_to_base64(PUBLIC_KEY)},
        sandbox=False,
    )
)

signing_keys takes every key a verifier should accept, so a rotation can publish both halves at once. The spec rejects anything that is not a base64 raw 32-byte Ed25519 key at construction, rather than letting the typo surface as an unexplainable signature failure on a caller.

4. Prove you agree on the bytes

The published wire vectors ship with the package, so byte agreement with §2 and §7.3 is a test in your own suite rather than a cloned repo:

from paygent_agent.testing import replay_vectors


def test_wire_vectors():
    failed = [result for result in replay_vectors() if not result.ok]
    assert not failed, failed

replay_vectors() re-derives every published value with this package. If your signing or hashing is your own rather than the SDK's, load_vector() and vector_bytes() hand back the published document and its raw bytes to diff against instead.

That is a unit check, not conformance. The §8 conformance harness is what says an agent is conformant; it speaks only HTTP to a --base-url, so it runs against any agent in any language.

Modules

In guide order, so the table doubles as a reading order for the contract itself.

Module § What it covers
paygent_agent — CONTRACT_VERSION and PROTOCOL_VERSION, and nothing else — importing the root pulls in no submodule
paygent_agent.card §1 The agent card — AgentCardSpec, build_agent_card, CAPABILITY
paygent_agent.keys §1 Ed25519 encodings — raw 32-byte base64, in and out
paygent_agent.signing §2 Server half — verify_request, sign_response, the signature bases, Content-Digest
paygent_agent.signing_client §2 Client half — RequestSigner, verify_response, for integration tests and for verifying Paygent's responses
paygent_agent.envelope §3 JSON-RPC framing, initialize / tools/list / tools/call dispatch, the capped body read, the §6 → JSON-RPC boundary
paygent_agent.errors §6 The error vocabulary — ErrorCode, WalletError, tool_result, tool_error
paygent_agent.kya §7 verify_mandate, items_hash, sign_approval, verify_approval
paygent_agent.canonical §7.3 RFC 8785 / JCS canonical JSON
paygent_agent.testing — The published vectors as package data, plus replay_vectors()

Every module states its surface in __all__, and py.typed ships, so a type checker sees real types rather than Any.

Bind your own approval domain

sign_approval and verify_approval prefix the bytes they sign with a domain separator, which defaults to APPROVAL_SIGNATURE_CONTEXT — the string "paygent-wallet-agent/approval/v1".

§7.3 makes the approval scheme vendor-internal: the value never leaves your process, and Paygent does not verify it. So the default crosses no trust boundary and interoperates with nothing — but it does name the reference implementation's repository rather than your service. Pass your own instead, and pass the same one to both calls:

from paygent_agent.kya import sign_approval, verify_approval

APPROVAL_CONTEXT = "acme-wallet/approval/v1"

proof = sign_approval(APPROVAL_KEY, approval, context=APPROVAL_CONTEXT)
verify_approval(
    proof,
    checkout_ref=checkout_ref,
    user_ref=user_ref,
    public_key=APPROVAL_PUBLIC_KEY,
    context=APPROVAL_CONTEXT,
)

Choose the string before you mint anything in production. Changing it later invalidates every approval signed under the old one, and the only symptom is approval_invalid on a proof that verified yesterday. Pin it with a test asserting the exact literal, the way this repo pins its own.

Versioning

The version tracks the contract, not any agent: paygent_agent.CONTRACT_VERSION is "1.1" and the distribution is 1.1.x. A patch is a fix in this package; a minor is a contract minor. Contract 1.x is frozen in the sense that matters — existing shapes, error codes, and verification rules will not change under it, and 1.1 is additive over 1.0.

Provenance

This package is extracted from paygent-wallet-agent, a clean-room reference implementation built from the guide alone. Every surface here is argued from docs/PAYMENT-AGENT-GUIDE.md; the reference agent dogfoods the package and gates its releases against the §8 conformance harness, so the SDK and a conformant agent are never checked apart.

Metadata

Release files for paygent-agent-sdk 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for paygent-agent-sdk 1.1.0
File Size Uploaded
paygent_agent_sdk-1.1.0.tar.gz 51.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for paygent-agent-sdk 1.1.0
File Interpreter ABI Platform
paygent_agent_sdk-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 95.3 kB

Release files / paygent_agent_sdk-1.1.0.tar.gz

Download URL paygent_agent_sdk-1.1.0.tar.gz
Size 51.5 kB
Tags Source
SHA-256 checksum
How to use checksums
cac0b0383a1e8fdca080f223ddf58cabdeaf42473dd1f17cebc76515ae876bf2
BLAKE2b-256 checksum
How to use checksums
a1b53ccc17edf993cd002b9b67f55d1a173292873d8d6832d8211d58133e2d31
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.8

Release files / paygent_agent_sdk-1.1.0-py3-none-any.whl

Download URL paygent_agent_sdk-1.1.0-py3-none-any.whl
Size 43.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c02b4e9624e86356bfd6ad7dc612d8b63785b894d040e7b6b4bf7f1433104a35
BLAKE2b-256 checksum
How to use checksums
464b4e0464e08a8e9e6cd47c3b15d704315f56bdc73d90d1172633baafc0beb2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.8

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page