cartwright
Guides

How an AI sale actually happens

End-to-end walkthrough — a buyer agent discovers your shop, negotiates and checks out, then where escrow, proof and audit stand in today's engine.

This guide follows a single transaction from "buyer agent has never heard of your shop" to "funds are in your Stripe account". Every step shows the actual HTTP call and the actual response. The point is to make the abstract concrete: when we say "headless merchant", this is the conversation that proves the claim.

The walkthrough assumes a Cartwright shop scaffolded with --template agent-marketplace (so brand.features.a2a and brand.features.acp are both true), with brand.acp.enabled and ecommerceEnabled then set to true by hand. The template leaves both false, and without them the feed (Step 3) and the ACP checkout (Step 5) answer 404. A pure-A2A shop, a pure-ACP shop, or a hybrid all draw from the same surface — they just answer 404 on the endpoints they opted out of.

Cast of characters

  • Your shop: example-shop.app — a Cartwright deploy
  • The buyer agent: a process somewhere on the internet acting for an end user (or for another business)
  • Stripe: the payment processor your shop authorised at setup
  • The audit ledger: the AgenticJWT rows the Guardian writes on the A2A calls

Step 1 — Discovery

The agent has a buyer intent ("find 100 wholesale sunglass frames in mahogany under DKK 65 per unit") and a list of candidate shops. It probes Cartwright's discovery surface to learn what example-shop.app is.

# Cartwright publishes four discovery hints
curl https://example-shop.app/llms.txt
curl https://example-shop.app/api/v1/tools
curl https://example-shop.app/.well-known/mcp.json

llms.txt is the LLM-crawler hint (free-form). /api/v1/tools is the typed catalogue of available actions. /.well-known/mcp.json is the MCP discovery card. Together they tell the agent: "this shop is reachable, here are the entry points, here are the auth requirements".

Step 2 — Agent Card fetch

The agent now wants the cryptographically-signed truth: who owns this shop, what they sell, what rails they accept, what their negotiation policy is.

curl https://example-shop.app/api/agent-card

Response (truncated):

{
  "payload": {
    "version": 1,
    "shopId": "example-shop.app",
    "shopName": "Atelier Sunglass Co.",
    "issuedAt": "2026-05-25T08:00:00.000Z",
    "expiresAt": null,
    "capabilities": [
      { "id": "sunglass-frames-wholesale",
        "name": "Sunglass frames, wholesale lots of 50–500 units",
        "anchorPriceMinor": 6500,
        "floorPriceMinor": 4200,
        "currency": "DKK" }
    ],
    "paymentRails": ["stripe", "escrow-poTE"],
    "negotiationPolicy": { "concessionRate": 0.5, "maxRounds": 5 }
  },
  "signature": "...",
  "publicKey": "..."
}

The agent verifies the ed25519 signature offline against the embedded public key. If it doesn't verify, the agent walks away — no negotiation with un-carded shops. See A2A endpoints for the signing setup.

Step 3 — Product feed

The Agent Card describes capabilities; the feed gives the actual SKUs. The agent pulls the JSONL product feed.

curl https://example-shop.app/api/acp/feed
{"item_id":"mahogany","title":"Mahogany frame, classic round","url":"https://example-shop.app/en/product/mahogany","price":"65.00 DKK","availability":"in_stock","brand":"Atelier"}
{"item_id":"walnut","title":"Walnut frame, wayfarer cut","url":"https://example-shop.app/en/product/walnut","price":"58.00 DKK","availability":"in_stock","brand":"Atelier"}

The agent matches the buyer intent against the feed (mahogany, 100 units, under DKK 65). mahogany at DKK 65 list price is at the ceiling; the agent decides to negotiate.

Step 4 — Negotiation

The agent obtains an API key from the shop out of band: the admin issues one at /admin/api-keys. /api/negotiate checks that the key is valid, not which scopes it carries; what the agent may do is the shop's agentic policy, which the Guardian checks. It then opens a negotiation.

curl -X POST https://example-shop.app/api/negotiate \
  -H "Authorization: Bearer <api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "buyer-northbound",
    "jti": "neg-7f3e9a1b-2026-05-25-001",
    "signedJwt": "eyJ...",
    "scopes": ["negotiate"],
    "floorMinor": 4200,
    "anchorMinor": 6500,
    "concessionRate": 0.5,
    "currentOffer": { "priceMinor": 6500, "quantity": 100, "validUntil": "2026-05-26T08:00:00Z" },
    "counterOffer": { "priceMinor": 5000, "quantity": 100, "validUntil": "2026-05-26T08:00:00Z" },
    "round": 1,
    "maxRounds": 5
  }'
{
  "decision": "counter",
  "nextOffer": { "priceMinor": 5750, "quantity": 100, "validUntil": "2026-05-26T08:00:00Z" },
  "reasoningCodes": ["COUNTER_BELOW_FLOOR", "CONCESSION_APPLIED"]
}

The Anchor-Resume engine is pure TypeScript — no LLM. It received an offer below the floor (DKK 50), concedes by half the gap between anchor (DKK 65) and the previous offer (DKK 65 → 57.50), returns DKK 57.50, and explains why with reasoning codes.

Two more rounds happen; the agent settles at DKK 56 per unit, total DKK 5600 for 100 units.

Step 5 — Checkout

The agent now creates an ACP checkout session. The ACP routes take no API key, so this call and complete send none. Two limits of today's engine shape this step: a session prices every line at the catalog price, with no field for the price Step 4 agreed, and one line takes 1 to 10 units. So the example orders 10 frames at list price. Each line item names its item with item_id, the id the feed listed in Step 3.

curl -X POST https://example-shop.app/api/acp/v1/checkout_sessions \
  -H "Content-Type: application/json" \
  -d '{
    "line_items": [
      { "item_id": "mahogany", "quantity": 10 }
    ],
    "buyer": { "email": "orders@northbound.example" },
    "fulfillment_address": {
      "name": "Northbound Buyer Agent / Atelier B2B",
      "address_line1": "Esplanaden 12",
      "postal_code": "1263",
      "city": "Copenhagen",
      "country": "DK"
    }
  }'

Response:

{
  "id": "acs_9b2d4f6a8c0e4a1b9d3f5a7c9e1b3d5f",
  "status": "ready_for_payment",
  "currency": "dkk",
  "line_items": [
    {
      "id": "mahogany",
      "product_id": "cm9x2k7q40002efgh",
      "variant_id": null,
      "slug": "mahogany",
      "sku": null,
      "name": "Mahogany frame, classic round",
      "quantity": 10,
      "unit_amount": 6500,
      "amount_total": 65000
    }
  ],
  "fulfillment_address": { "name": "Northbound Buyer Agent / Atelier B2B", "address_line1": "Esplanaden 12", "postal_code": "1263", "city": "Copenhagen", "country": "DK" },
  "fulfillment_option": null,
  "buyer": { "email": "orders@northbound.example", "name": null, "phone": null },
  "totals": { "subtotal": 65000, "total_shipping": 0, "total_discount": 0, "amount_total": 65000 },
  "messages": []
}

Pricing was computed server-side by lib/pricing.ts from the catalog price, in minor units: the subtotal, the shop's flat shipping rate (waived from its free-shipping threshold, DKK 499 by default, so 0 here) and any discount_code. There is no separate tax line. product_id is the shop's internal database id; it appears in responses only and is not an input field.

curl -X POST https://example-shop.app/api/acp/v1/checkout_sessions/acs_9b2d4f6a8c0e4a1b9d3f5a7c9e1b3d5f/complete \
  -H "Content-Type: application/json" \
  -d '{ "shared_payment_token": "spt_…", "idempotency_key": "ckt-7f3e9a1b-2026-05-25-001" }'

complete charges the Stripe Shared Payment Token and creates the Order from the session's line items. It confirms the PaymentIntent on the spot (confirm: true, off_session: true, automatic capture) and expects succeeded, so the money has moved once it returns, with no hold left for a proof to release. It answers 501 until the shop sets ACP_PAYMENT_COMPLETION=1; see ACP.

Step 6 — Escrow, as the engine stands

No engine code opens or funds an escrow row. The EscrowTransaction table exists (prisma/schema.prisma), but nothing creates a row: not the ACP session, not complete, and not the Stripe webhook at /api/webhook/stripe, which handles payment events for orders and never touches the table. There is no GET for an escrow; POST /api/escrow/verify is the only escrow route. Step 7 therefore applies only to a row your own code writes, with status funded.

Step 7 — Proof submission

The agent submits a Proof-of-Task-Execution against that row. A delivery proof carries the carrier and its trackingNumber.

curl -X POST https://example-shop.app/api/escrow/verify \
  -H "Authorization: Bearer <api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "buyer-northbound",
    "jti": "rel-7f3e9a1b-2026-05-25-001",
    "signedJwt": "eyJ...",
    "scopes": ["escrow.release"],
    "escrowTxId": "cm9x2k7q40003ijkl",
    "proofType": "delivery",
    "proofPayload": {
      "carrier": "GLS",
      "trackingNumber": "JJD000123456789"
    }
  }'

The handler (app/api/escrow/verify/route.ts):

  1. Answers 404 unless brand.features.a2a is on, and 422 on a body its schema refuses.
  2. Authenticates the Bearer token (401 without a valid key). The key's scopes are not checked.
  3. Loads the escrow row (404 escrow_not_found without one).
  4. Runs the Guardian middleware: the shop's agentic policy must allow escrow.release for this agent, the jti must be new, the amount must be within the policy's cap and the payment rail on its allowlist (403 otherwise). It writes one AgenticJWT row either way. The scopes in the body are recorded in that row, not checked.
  5. Refuses a row that is not funded or disputed (409).
  6. Runs the verifier and writes a PoTEProof row, whatever the result. Only a pass moves the row to released.

Today no proof type passes. delivery, signature and webhook proofs are recorded as pending, and a hash proof fails, because the route has no expected hash to compare with. So the row stays funded:

{
  "escrowStatus": "funded",
  "proofId": "cm9x2k7q40004mnop",
  "verifierResult": "pending",
  "verifierMessage": "Delivery proof recorded; awaiting carrier webhook confirmation."
}

Step 8 — Funds disbursement

There is nothing for escrow to disburse: Step 5's complete already captured the charge, and the funds follow your Stripe payout schedule from there. A proof that fails or pends leaves the escrow row as it was; it does not move it to disputed. /admin/agentic is read-only: it counts escrow rows by status and lists the disputed ones, and force-release and force-refund are not built yet.

Step 9 — Audit

The rows this walkthrough writes are AgenticJWT rows, one per Guardian call, pass or fail, with the request path, the jti and the scopes the agent sent. The Agent Card fetch, the feed and the ACP routes write none, in AgenticJWT or AuditLog. With adminAgenticDashboard on, /admin/agentic lists the last 24 hours of them, up to 50.

StepRequestAgenticJWT row
2GET /api/agent-cardnone
3GET /api/acp/feednone
4POST /api/negotiate, rounds 1 to 3one per round, pass
5ACP create and completenone
7POST /api/escrow/verifyone, pass (the Guardian's verdict; the proof's own result is in the PoTEProof row)

What didn't happen

Read what's not in the walkthrough — that's where the security work is:

  • No LLM was asked to decide the price. The Anchor-Resume engine is pure TS; the P2K scanner gates every commit that would try.
  • No negotiation round and no proof skipped the Guardian: /api/negotiate and /api/escrow/verify run it before they act, and they are the only routes that do. The Agent Card fetch (GET /api/agent-card) is A2A-gated but runs no Guardian: whoever reaches it gets the card, whatever the policy's agent-card.read scope or blocked-agent list says. The ACP routes, complete included, run none either; their gates are brand.acp.enabled, the schemas and ACP_PAYMENT_COMPLETION.
  • No escrow was released on an unverified proof, or on any proof yet: only a pass moves a row to released, and today's verifier passes none (Step 7).
  • No agent negotiated or submitted a proof outside the shop's agentic policy: the Guardian checks negotiate and escrow.release against it. With no policy set it allows nothing, and no admin screen or tool sets one yet, so the pass rows in Step 9 need a policy written straight to BrandingSettings.agenticPolicyJson. An API key's own scopes are not checked on these routes, and the ACP routes take no key.

The walkthrough is a happy path. The Guardian + escrow + audit machinery is most of the code; it just happens to stay quiet when the happy path is followed.

On this page