diff --git a/.changeset/ucp-commerce-agents.md b/.changeset/ucp-commerce-agents.md new file mode 100644 index 0000000000..6e0b0407f6 --- /dev/null +++ b/.changeset/ucp-commerce-agents.md @@ -0,0 +1,16 @@ +--- +"eve": patch +--- + +Add `eve/commerce/ucp` for building agents that buy over the Universal Commerce +Protocol. `defineUcpConnection` connects to a merchant's UCP shopping service +and takes the protocol headers off the model — agent profile identity, +retry-safe `Idempotency-Key`/`Request-Id` derived from the replay-stable call +id, and RFC 9421 request signing. `resolveUcpCheckoutHandoff` collapses a +checkout response into one typed outcome: conversational continuation, +`continue_url` redirect, or embedded checkout. + +OpenAPI connections also gain a `prepareRequest` hook, which receives the +fully-built request — including the serialized body — and merges the headers it +returns. Use it for signatures and content digests that `headers` cannot +express. diff --git a/apps/templates/commerce-agent/.env.example b/apps/templates/commerce-agent/.env.example new file mode 100644 index 0000000000..a8fee8fecf --- /dev/null +++ b/apps/templates/commerce-agent/.env.example @@ -0,0 +1,20 @@ +# The merchant's UCP shopping endpoint, from +# services["dev.ucp.shopping"][transport="rest"].endpoint in their +# https:///.well-known/ucp profile. +UCP_MERCHANT_ENDPOINT="https://merchant.example.com/ucp/v1" + +# Credential the merchant issued you. UCP allows an API key or OAuth token +# instead of message signatures. +UCP_MERCHANT_TOKEN="" + +# Public https origin serving this app's /.well-known/ucp. Merchants fetch it +# to resolve who is calling, so it must be reachable from their network. +# Unset on Vercel, where VERCEL_PROJECT_PRODUCTION_URL is used instead. +UCP_AGENT_ORIGIN="" + +# Optional: sign every request with HTTP Message Signatures (RFC 9421). +# UCP_SIGNING_KEY_ID must match the `kid` published in /.well-known/ucp. +# Generate a P-256 key with: +# node --input-type=module -e 'const k=await crypto.subtle.generateKey({name:"ECDSA",namedCurve:"P-256"},true,["sign"]);console.log(JSON.stringify(await crypto.subtle.exportKey("jwk",k.privateKey)))' +UCP_SIGNING_KEY_ID="" +UCP_SIGNING_KEY_JWK="" diff --git a/apps/templates/commerce-agent/.gitignore b/apps/templates/commerce-agent/.gitignore new file mode 100644 index 0000000000..93fab4545e --- /dev/null +++ b/apps/templates/commerce-agent/.gitignore @@ -0,0 +1,48 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# eve +.eve +.swc +.output + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env*.local diff --git a/apps/templates/commerce-agent/.vercelignore b/apps/templates/commerce-agent/.vercelignore new file mode 100644 index 0000000000..44fb90d710 --- /dev/null +++ b/apps/templates/commerce-agent/.vercelignore @@ -0,0 +1,9 @@ +.output +.swc +.eve +.turbo +.agents +.github +dist +research +node_modules diff --git a/apps/templates/commerce-agent/README.md b/apps/templates/commerce-agent/README.md new file mode 100644 index 0000000000..0e45cf7424 --- /dev/null +++ b/apps/templates/commerce-agent/README.md @@ -0,0 +1,84 @@ +# Commerce agent template + +An eve agent that shops one merchant over the +[Universal Commerce Protocol](https://ucp.dev/), with a Next.js front end that +renders the checkout handoff. + +The template is the buying side of UCP. It does not implement a store: it +talks to a merchant that already publishes a UCP profile. + +## What is in here + +``` +agent/ + agent.ts the agent + instructions.md how it works a checkout and when it stops + connections/merchant.ts defineUcpConnection against the merchant endpoint + channels/eve.ts chat transport for the browser client + channels/ucp.ts this agent's own /.well-known/ucp profile +lib/ucp.ts identity, signing key, and a small read client +app/ + _commerce/Chat.tsx minimal chat, tracks the active checkout id + _commerce/CheckoutHandoff.tsx renders every branch of the handoff union + api/checkout/[id]/route.ts re-reads the session and resolves the handoff +``` + +## Setup + +```sh +cp .env.example .env.local # fill in the merchant endpoint and token +pnpm install +pnpm dev:eve # eve dev +pnpm dev # next dev, in a second terminal +``` + +`agent/connections/merchant.ts` throws at build time if +`UCP_MERCHANT_ENDPOINT` or `UCP_AGENT_ORIGIN` is missing, rather than sending +requests a merchant will reject. + +### Local development and the agent profile + +A merchant resolves who is calling by fetching the URL in your `UCP-Agent` +header, so that URL has to be reachable from the merchant's network. +`http://localhost:3000/.well-known/ucp` is not. Either point +`UCP_AGENT_ORIGIN` at a tunnel (`ngrok http 3000`), or use the example agent +profile the merchant publishes for testing, if they have one. + +Check what you are serving: + +```sh +curl -i "$UCP_AGENT_ORIGIN/.well-known/ucp" +``` + +### Signing + +Signing is optional: UCP accepts an API key or OAuth token instead. Set +`UCP_SIGNING_KEY_ID` and `UCP_SIGNING_KEY_JWK` and every request is signed per +RFC 9421, with the public half published in `/.well-known/ucp` so the merchant +can verify it. Merchants that require signatures answer unsigned requests with +`signature_missing`. + +## How the handoff works + +The agent drives the checkout through connection tools. Each response says +what has to happen next, and `resolveUcpCheckoutHandoff` turns that into one +of three outcomes the UI can render: + +- **conversational** — the agent keeps working: collect what is missing and + call Update Checkout, or the session is ready and waiting on the buyer. +- **embedded** — the merchant enabled Embedded Checkout for this session, so + their checkout loads in an iframe with the negotiated `ec_*` parameters. +- **continue_url** — the buyer finishes on the merchant's own site. + +Plus the terminal `completed`, `canceled`, and `failed`. + +The agent never places the order on its own. UCP requires the buyer to review +and authorize a checkout in a trusted UI, and the instructions in +`agent/instructions.md` hold the agent to that. + +## Read next + +- [UCP commerce agents](../../../docs/protocols/ucp-agent.mdx) — the preset and + the handoff contract in detail. +- [Universal Commerce Protocol (UCP)](../../../docs/protocols/ucp.mdx) — + publishing your own profile as a business. diff --git a/apps/templates/commerce-agent/agent/agent.ts b/apps/templates/commerce-agent/agent/agent.ts new file mode 100644 index 0000000000..af4b36659b --- /dev/null +++ b/apps/templates/commerce-agent/agent/agent.ts @@ -0,0 +1,5 @@ +import { defineAgent } from "eve"; + +export default defineAgent({ + model: "anthropic/claude-opus-4.6", +}); diff --git a/apps/templates/commerce-agent/agent/channels/eve.ts b/apps/templates/commerce-agent/agent/channels/eve.ts new file mode 100644 index 0000000000..37ca329ef4 --- /dev/null +++ b/apps/templates/commerce-agent/agent/channels/eve.ts @@ -0,0 +1,13 @@ +import { localDev, vercelOidc } from "eve/channels/auth"; +import { eveChannel } from "eve/channels/eve"; + +/** + * The chat transport the browser client talks to. + * + * `localDev` opens it up on `eve dev`; `vercelOidc` covers deployed + * preview and production. Add your own `AuthFn` ahead of these before + * exposing a real storefront to real buyers. + */ +export default eveChannel({ + auth: [vercelOidc(), localDev()], +}); diff --git a/apps/templates/commerce-agent/agent/channels/ucp.ts b/apps/templates/commerce-agent/agent/channels/ucp.ts new file mode 100644 index 0000000000..27ad7eac35 --- /dev/null +++ b/apps/templates/commerce-agent/agent/channels/ucp.ts @@ -0,0 +1,27 @@ +import { defineChannel, GET } from "eve/channels"; +import { agentProfile } from "@/lib/ucp"; + +/** + * Serves this agent's UCP profile. + * + * UCP has no registration step: a merchant learns who is calling by + * fetching the URL in the `UCP-Agent` header, and finds the public key to + * verify signatures in this document's `signing_keys`. If this endpoint is + * unreachable, merchants answer with `profile_unreachable` (HTTP 424). + * + * The spec requires HTTPS, forbids 3xx responses, and requires a + * `Cache-Control` of `public` with `max-age` of at least 60 seconds. + */ +export default defineChannel({ + cors: true, + routes: [ + GET("/.well-known/ucp", async () => { + return new Response(JSON.stringify(agentProfile()), { + headers: { + "cache-control": "public, max-age=300", + "content-type": "application/json", + }, + }); + }), + ], +}); diff --git a/apps/templates/commerce-agent/agent/connections/merchant.ts b/apps/templates/commerce-agent/agent/connections/merchant.ts new file mode 100644 index 0000000000..46fe0f5042 --- /dev/null +++ b/apps/templates/commerce-agent/agent/connections/merchant.ts @@ -0,0 +1,29 @@ +import { defineUcpConnection, type UcpConnectionDefinition } from "eve/commerce/ucp"; +import { agentMetadata, merchantEndpoint, signingKey } from "@/lib/ucp"; + +const definition: UcpConnectionDefinition = { + agent: agentMetadata(), + auth: { + getToken: async () => ({ token: process.env.UCP_MERCHANT_TOKEN ?? "" }), + }, + description: + "The merchant's UCP shopping service: search the catalog and drive a checkout session.", + endpoint: merchantEndpoint(), + // Checkout and catalog are what this template drives. Widen the list + // once the merchant's profile advertises other capabilities you want. + operations: { + allow: [ + "cancel_checkout", + "complete_checkout", + "create_checkout", + "get_checkout", + "lookup_catalog", + "search_catalog", + "update_checkout", + ], + }, +}; + +const signing = signingKey(); + +export default defineUcpConnection(signing === undefined ? definition : { ...definition, signing }); diff --git a/apps/templates/commerce-agent/agent/instructions.md b/apps/templates/commerce-agent/agent/instructions.md new file mode 100644 index 0000000000..8b9658650c --- /dev/null +++ b/apps/templates/commerce-agent/agent/instructions.md @@ -0,0 +1,41 @@ +You are a shopping assistant for one merchant. You help a buyer find items +and get a checkout session ready, and you hand the buyer the controls before +an order is placed. + +## Working the checkout + +Every `merchant__*_checkout` response carries a `status` and a `messages` +array. Read both before deciding what to do next. + +- `incomplete`: something is missing or contested. Read `messages`. For each + error with `severity: "recoverable"`, gather what is needed — ask the buyer + in the conversation if you do not already have it — and call + `merchant__update_checkout` with the full checkout resource. Update + replaces the resource, so send every field you want to keep. +- `ready_for_complete`: everything is collected. Do not place the order + yourself. Tell the buyer the checkout is ready and let them review it. +- `complete_in_progress`: the merchant is placing the order. Call + `merchant__get_checkout` to follow it rather than retrying the completion. +- `requires_escalation`: the buyer has to take over on the merchant's own + surface. Say what is needed, in the buyer's words, and stop calling + checkout operations. The app renders the handoff. +- `completed` / `canceled`: terminal. Report the order or offer to start over. + +Any message with `severity: "requires_buyer_input"` or +`"requires_buyer_review"` means the buyer must act, whatever the status says. +Summarize it and stop. + +## What you never do + +- Never invent buyer details: addresses, emails, and payment instruments come + from the buyer or from what the merchant already has. +- Never call `merchant__complete_checkout` on your own initiative. The buyer + reviews and authorizes the order; you prepare it. +- Never state a total, tax, shipping cost, or availability that did not come + from a merchant response. + +## Talking to the buyer + +Be brief and concrete. Name items, quantities, and amounts exactly as the +merchant reported them. When you are blocked, say what you need in one +sentence and ask for that one thing. diff --git a/apps/templates/commerce-agent/app/_commerce/Chat.tsx b/apps/templates/commerce-agent/app/_commerce/Chat.tsx new file mode 100644 index 0000000000..9911584b01 --- /dev/null +++ b/apps/templates/commerce-agent/app/_commerce/Chat.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { defaultMessageReducer, useEveAgent, type EveMessage } from "eve/react"; +import { type FormEvent, useMemo, useState } from "react"; + +import { CheckoutHandoff } from "./CheckoutHandoff"; + +const MERCHANT_TOOL_PREFIX = "merchant__"; + +export function Chat() { + const reducer = useMemo(() => defaultMessageReducer(), []); + const agent = useEveAgent({ reducer }); + const [draft, setDraft] = useState(""); + + const messages = agent.data.messages; + const checkout = latestCheckout(messages); + const isBusy = agent.status === "submitted" || agent.status === "streaming"; + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + const text = draft.trim(); + if (text.length === 0 || isBusy) { + return; + } + setDraft(""); + await agent.send(text); + } + + return ( +
+
+
    + {messages.map((message) => ( +
  • +
    {renderText(message)}
    +
  • + ))} + {isBusy ? ( +
  • +
    Thinking…
    +
  • + ) : null} +
+ +
+ setDraft(event.target.value)} + placeholder="What are you shopping for?" + value={draft} + /> + +
+
+ + {checkout === undefined ? null : ( + // Keying on the tool call remounts the panel for each new merchant + // response, so the handoff is re-resolved from fresh state. + + )} +
+ ); +} + +function renderText(message: EveMessage): string { + return message.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); +} + +/** + * Finds the checkout the merchant most recently reported on. + * + * The id is all this takes from the stream; the panel re-reads the + * session server-side rather than trusting the browser's copy. + */ +function latestCheckout( + messages: readonly EveMessage[], +): { checkoutId: string; toolCallId: string } | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const parts = messages[index]?.parts ?? []; + for (let partIndex = parts.length - 1; partIndex >= 0; partIndex--) { + const part = parts[partIndex]; + if ( + part === undefined || + part.type !== "dynamic-tool" || + part.state !== "output-available" || + !part.toolName.startsWith(MERCHANT_TOOL_PREFIX) + ) { + continue; + } + const checkoutId = readCheckoutId(part.output); + if (checkoutId !== undefined) { + return { checkoutId, toolCallId: part.toolCallId }; + } + } + } + return undefined; +} + +function readCheckoutId(output: unknown): string | undefined { + if (typeof output !== "object" || output === null) { + return undefined; + } + const body = (output as { body?: unknown }).body; + if (typeof body !== "object" || body === null) { + return undefined; + } + const id = (body as { id?: unknown }).id; + return typeof id === "string" && id.length > 0 ? id : undefined; +} diff --git a/apps/templates/commerce-agent/app/_commerce/CheckoutHandoff.tsx b/apps/templates/commerce-agent/app/_commerce/CheckoutHandoff.tsx new file mode 100644 index 0000000000..7204537be3 --- /dev/null +++ b/apps/templates/commerce-agent/app/_commerce/CheckoutHandoff.tsx @@ -0,0 +1,134 @@ +"use client"; + +import type { UcpCheckoutHandoff } from "eve/commerce/ucp"; +import { useEffect, useState } from "react"; + +/** + * Renders the three ways a checkout can continue. + * + * Every branch of the union is handled here, which is the point of the + * contract: `conversational` means the agent is still working and the panel + * stays out of the way, `embedded` means the merchant's own checkout can be + * framed in place, and `continue_url` means the buyer leaves for the + * merchant's site. + */ +export function CheckoutHandoff(props: { readonly checkoutId: string }) { + const handoff = useHandoff(props.checkoutId); + + if (handoff === undefined) { + return ; + } + + switch (handoff.kind) { + case "conversational": + return ( + + ); + + case "embedded": + return ( +