Skip to main content

UCP: shopping, a purchase approved once

Agent Runtimes speaks the Universal Commerce Protocol 2026-08-25 as a shopper, in Python and TypeScript, over its REST binding.

PythonTypeScript
Packageagent_runtimes.protocols.ucp@datalayer/agent-runtimes/lib/protocols/ucp
Modelsucp-sdk 0.5.0 (generated against 2026-08-25)@ucp-js/sdk 0.5.1 (the protocol's own, zod)
Pluginagent-runtimes.protocols.ucpUcpPlugin

A purchase is a proposal​

Nothing is bought in one call. A ready_for_complete checkout becomes an exact proposal — its lines, prices, totals and currency, and a revision (the SHA-256 of what is bought) — that the person approves. Only then is the checkout completed, once:

client = UcpClient(business="https://store.example", agent_profile=PROFILE_URL)
await client.discover()
checkout = await client.create_checkout([{"id": "tea_sencha", "quantity": 2}])
checkout = await client.update_checkout(checkout["id"], {"buyer": {"email": "ana@example.com"}})
proposal = await client.propose_completion(checkout["id"])
proposal.summary() # 'Buy 2 × Sencha green tea, 100 g at store.example for USD 27.00'
order = await client.complete(
proposal,
approver=approver_of(platform),
instruments=instruments_of(platform),
store=proposal_store_of(platform),
)

complete is refused when the proposal was used already (it is spent before the person is asked, so a declined one is proposed again, never retried), when the person declines, when the checkout changed since it was proposed, or when it expired. The proposal's id is the Idempotency-Key, so a retry after a lost answer is the same request.

Every request carries UCP-Agent: profile="…", a Request-Id, and on a change an Idempotency-Key. A host that signs its agents' requests passes the Web Bot Auth httpx auth to the client's httpx.AsyncClient; UCP 2026-08-25 accepts Web Bot Auth's Signature-Agent for RFC 9421 signatures.

The plugin and its points​

PointDefaultWhat a host contributes
ucp.approvernone: nothing is boughthow the person approves (on Datalayer, Tool Approvals and approvals in advance)
ucp.paymentInstrumentsnonethe payment instrument, from custody: never seen by a model
ucp.proposalStorememorya store shared by its processes
ucp.extension.fulfillment, ucp.extension.discount, ucp.extension.buyer_consentnonean implementation of that UCP capability extension

UCP's capability extensions are contribution points of the plugin; negotiate_capabilities(profile, platform) answers checkout and the extensions both the business offers and a contribution implements.

Action classes​

search, get_checkout and, after the sale, get_order are read (and propose_completion, which only reads); creating, updating and cancelling a checkout are write; complete is buy (ACTION_CLASSES, agentspecs' protocol-tools/ucp.yaml without the gateway tools' ucp_ prefix), so a Rule decides each the way it decides any tool.

After the sale​

get_order(order_id) (TypeScript getOrder) reads an order the business placed (GET /orders/{id}), validated by ucp-sdk's Order (@ucp-js/sdk's OrderSchema): its lines, how many of each are fulfilled and their status, its totals and its link. order_summary (orderSummary) is what an agent may see of it. The test merchant, Example Store, answers each order it placed, every line processing.

In an application​

An application lists ucp in protocols and names each merchant in its commerce block (commerce.merchants[]: protocol: ucp, the merchant's domain, a level and, to buy, the most one purchase may cost, limit). For now Datalayer shops at test merchants only, with no real payment (decided 2026-10-13). On Datalayer the runtime holds nothing of it: each merchant is one server, ucp_<domain> (ucp_store_example), which is the MCP gateway's ucp toolset (/mcp?only=ucp&ucp-merchant=<domain>) reached in the person's name; its tools are named with it (ucp_store_example_ucp_complete).

The application's Rules decide those tools by what agentspecs says each does (protocol-tools/ucp.yaml): reading reads, filling a checkout writes, ucp_complete buys. A purchase is always asked of the person, by the gateway, with the exact checkout — whatever the rule on buying says, the runtime lets the call reach the gateway rather than ask twice — and made at most once; Leave it to me buys nothing. A connection that reads gives only what reads.

Reading a business's profile​

UcpClient.discover() reads /.well-known/ucp through agent_runtimes.protocols.egress.metadata_fetch, the PAP SDK's resolving client (create_resolving_client, the one egress path Datalayer uses towards companies, businesses and key directories): the name is resolved first and refused unless https with every address public, only that address is dialled, no redirect is followed, no proxy is used, and the profile is bounded (256 KiB). That is the default; a local test merchant is read over the client you trust by saying so — metadata=client_fetch(http), as the example and the tests do (test_protocols_metadata_egress.py).

The test merchant​

agent_runtimes.protocols.ucp.merchant.ExampleStore is a local UCP business built from the specification's examples: its profile, catalog search and the checkout lifecycle, holding the platform to UCP-Agent, Request-Id and Idempotency-Key (409 on a reused key with another body); every answer is validated by ucp-sdk before it is sent.

Example​

Runs locally, against the test merchant above, in one process with no network and no model (example EX-04):

python examples/protocols/ucp/shopping.py --yes

It prints the store's profile (store.example speaks UCP 2026-08-25), a catalog search, a checkout incomplete until the buyer's email is given, then ready_for_complete; the exact purchase to approve (Buy 2 × Sencha green tea, 100 g at store.example for USD 27.00), the order once bought, and the same proposal refused a second time (this proposal was used already). Without --yes it asks you to approve. The TypeScript client runs against an in-memory twin of the merchant (ucp.unit.test.ts) and against the Python merchant itself over HTTP: merchant_server.py serves Example Store on localhost and ucpMerchant.integration.test.ts shops there (discovery, search, a checkout, the purchase approved and bought once, the same proposal refused); test_protocols_ucp_two_languages.py runs both.

python examples/protocols/ucp/merchant_server.py --port 8812
UCP_MERCHANT=http://127.0.0.1:8812 npx vitest run --project=integration \
src/protocols/ucp/ucpMerchant.integration.test.ts

In the browser​

The examples gallery's UCP checkout (Agent protocols group, example EX-10) buys from the same store, in TypeScript, in the page: src/protocols/ucp/merchant.ts is Example Store as a fetch (its catalogue, totals, email gate, idempotency and test payment; every answer read by the client's @ucp-js/sdk schema). Propose puts two Sencha teas in a checkout and proposes the exact purchase; Complete the purchase asks the host's ucp.approver, which is the card on the page — Approve buys once (the order shown), Decline buys nothing, and the same proposal is refused after (this proposal was used already). The test payment's token is held by the page's ucp.paymentInstruments and appears nowhere.

EXAMPLE=ProtocolUcpCheckoutExample npm run dev

Its work is src/examples/protocols/ucpCheckoutDemo.ts, run by src/examples/__tests__/protocolExamples.test.ts.

Conformance​

UCP 2026-08-25. ucp/fixtures/checkout.json (a checkout and its revision digest) is read by test_protocols_ucp.py and ucp.unit.test.ts: both languages compute the same digest. Every answer of the test merchant is validated by ucp-sdk; the TypeScript client validates with @ucp-js/sdk. All protocols side by side: Conformance.

What never reaches a model​

The payment token goes from the host's ucp.paymentInstruments to the business and nowhere else: checkout_summary and proposal.summary() are what an agent sees, and the tests check that no token is in them.