Metadata-Version: 2.4
Name: paygent-agent-sdk
Version: 1.1.0
Summary: Signing, KYA, and MCP envelope primitives for building a Paygent wallet agent
Requires-Python: >=3.14
Description-Content-Type: text/markdown
Requires-Dist: cryptography>=44
Requires-Dist: pydantic>=2.10

# `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

```bash
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/`](https://github.com/paygent-labs/paygent-wallet-agent/tree/master/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.

```python
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:

```python
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

```python
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).

```python
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:

```python
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:

```python
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`](https://github.com/paygent-labs/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.
