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.
| Python | TypeScript | |
|---|---|---|
| Package | agent_runtimes.protocols.ucp | @datalayer/agent-runtimes/lib/protocols/ucp |
| Models | ucp-sdk 0.5.0 (generated against 2026-08-25) | @ucp-js/sdk 0.5.1 (the protocol's own, zod) |
| Plugin | agent-runtimes.protocols.ucp | UcpPlugin |
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
| Point | Default | What a host contributes |
|---|---|---|
ucp.approver | none: nothing is bought | how the person approves (on Datalayer, Tool Approvals and approvals in advance) |
ucp.paymentInstruments | none | the payment instrument, from custody: never seen by a model |
ucp.proposalStore | memory | a store shared by its processes |
ucp.extension.fulfillment, ucp.extension.discount, ucp.extension.buyer_consent | none | an 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.