> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zap.wzrd.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Commerce agents

> The contract between a commerce Zap and air: what an agent may do, the catalog entry it writes, and what happens after the owner approves.

A commerce agent is a Zap that ends in a storefront listing instead of a video.
The Zap generates the art, stages a catalog entry inside the creator's air box,
and files a decision. The owner approves it once in air's Needs You queue, and
the listing is buyable through Stripe Connect. The Zap never publishes and
never charges: it only stages. This page is the contract between the two
sides, `gratitude5dee/zap` (the agent) and `gratitude5dee/airv2` (the rails).

Read [Commerce](/commerce/overview) first for the step syntax. This page covers
what a commerce agent is allowed to do, the exact catalog entry it writes, and
what air does with it.

## Run the bundled agents

```bash theme={"system"}
npx @wzrdtech/zap run merch-drop --input image=./selfie.png --input PRODUCT_NAME="Tour Tee" --input PRICE_CENTS=3500 --json
npx @wzrdtech/zap run merch-drop --input image=./selfie.png --input PRODUCT_NAME="Tour Tee" --input PRICE_CENTS=3500 --live
npx @wzrdtech/zap run event-ticket --input EVENT_NAME="Neon Wolf Live" --input EVENT_DATE="Fri 9pm" --input VENUE="online" --input POSTER_PROMPT="neon wolf, night city" --input PRICE_CENTS=2500 --live
```

`--json` is plan-only: it quotes the media spend, reports `charges: false`, and
writes nothing. `--live` is required to stage, and it must run inside the
creator's air box (or with `ZAP_AIR_API_BASE` and `ZAP_AIR_GATEWAY_TOKEN` set).
Without either, the step fails closed with `COMMERCE_UNCONFIGURED` before it
touches the catalog.

| Recipe | Path | Listing kind | What the buyer gets |
| - | - | - | - |
| `merch-drop` | `agent/skills/zap-merch-drop/` | `physical` | an order the owner ships |
| `event-ticket` | `agent/skills/zap-event-ticket/` | `event_ticket` | a `ticket_code` that scans once |

## What a commerce agent may do

Two step kinds exist, and both only file an owner decision:

* `commerce.stage_listing` merges one entry into
  `~/.hermes/miniapps/shop/catalog.json` (`{ "items": [...] }`), then
  `POST /api/miniapps/commerce` with `{"action":"publish_catalog"}`. air files
  a pending `shop_publish` decision, or reuses the one already open
  (`decisionReused: true`).
* `commerce.payment_request` posts `{"action":"payment_request", ...}`. air
  files a pending `payment_request` decision.

Everything else is off limits. There is no step that creates a checkout, calls
Stripe, edits `storefront_products`, or approves a decision. The hosted runner
at `zap.wzrd.tech` refuses live commerce runs (plan-only runs still work there);
staging belongs to the box that owns the catalog.

## The catalog entry

Every listing step writes an entry of this shape. This is the shared fixture
both repos test against (`tests/commerce-steps.test.ts` here,
`apps/web/lib/commerce/commerce.test.ts` in airv2 under
`Zap-staged listings (commerce.stage_listing)`):

```json theme={"system"}
{
  "key": "neon-wolf-tee",
  "kind": "physical",
  "name": "Neon Wolf Tee",
  "description": "Generated by the merch-drop Zap.",
  "imageUrl": "https://media.wzrd.tech/u/casey/media/abc123-product_art.png",
  "priceCents": 3500,
  "inventory": 100,
  "active": true,
  "source": { "zap": "merch-drop", "runId": "run_xxx", "stepId": "listing" }
}
```

The bundled `merch-drop` recipe fills the same fields from its own inputs:
`description` is
`Limited merch drop: {PRODUCT_NAME}. Art generated from the creator's own photo.`,
`inventory` is `user.INVENTORY` (`null` when left empty), and `source.stepId`
is the recipe's step id, `stage_listing`.

For tickets, `kind` is `event_ticket` and `inventory` is the ticket count, or
`null` for unlimited.

air's `sanitizeCatalogItem` (`apps/web/lib/commerce/catalog.ts`) decides what
survives approval:

| Field | Rule |
| - | - |
| `key` | `^[a-z0-9][a-z0-9_-]{0,63}$`; derived from `name` unless set. Re-running upserts by key. |
| `kind` | one of `physical`, `digital`, `service`, `event_ticket` |
| `name` | trimmed, at most 200 characters |
| `description` | at most 2000 characters |
| `priceCents` | integer, 1 to 10,000,000 (one cent to \$100k) |
| `inventory` | integer at least 0, or `null` for unlimited |
| `imageUrl` | must start with air's `R2_PUBLIC_BASE_URL` (`https://media.wzrd.tech`); anything else becomes `null` |
| `active` | anything but `false` is `true` |
| `source` | ignored; never reaches `storefront_products` |

Fields are camelCase. air also accepts the snake\_case aliases `price_cents`
and `image_url`. When both spellings are present the camelCase key wins, even
if its value is `null`, so `"imageUrl": null` clears an image rather than
falling back to a stale `image_url`. The Zap CLI always writes camelCase; the
aliases exist for hand-edited catalogs.

The image URL comes from `POST /api/media/publish`, which the live step calls
before writing the entry. If the upload is refused, the entry is still staged
with `imageUrl: null` and the run reports why in `imageNote`. A remote image URL
passed as the input is kept only when it is already under air's media base
(`ZAP_AIR_MEDIA_BASE`, default `https://media.wzrd.tech`); any other host stages
as `imageUrl: null` up front rather than being silently dropped at approval.

## What air does after staging

| Stage | Where | Result |
| - | - | - |
| Stage | Zap, inside the box | catalog entry written, `shop_publish` pending, `storefront_products` unchanged |
| Approve | owner, in Needs You (`apps/web/app/api/decisions/route.ts`) | `applyCatalogPublish` sanitizes every entry and upserts `storefront_products`; missing keys are deactivated |
| Checkout | buyer, storefront mini-app (`apps/web/lib/commerce/checkout.ts`) | `startCheckout(productKey, quantity)` reads `price_cents` from the projected row and opens a Stripe Connect direct charge |
| Fulfil | Stripe webhook (`apps/web/app/api/inbound/stripe/route.ts`) | `fulfillCheckoutSession` marks the order `paid` once, decrements inventory, mints `ticket_code` for `event_ticket` |
| Check in | owner, at the door | `checkInTicket` accepts a code exactly once |

A staged-but-unapproved listing has no product row, so `startCheckout` throws
`not found` and Stripe is never called. The airv2 test block asserts every row
of this table.

## Live run output

A live `commerce.stage_listing` step returns:

```json theme={"system"}
{
  "status": "staged",
  "charges": false,
  "decisionId": "dec_...",
  "decisionReused": false,
  "replaced": false,
  "catalogPath": "/home/box/.hermes/miniapps/shop/catalog.json",
  "imageNote": "uploaded to the air media lane",
  "listing": { "key": "neon-wolf-tee", "...": "..." },
  "message": "Listing staged. Approve the shop_publish decision in air (Needs you) to make it buyable."
}
```

Agents report the `decisionId`. They never report a purchase URL, and never
describe the listing as live; `agent/instructions.md` spells this out for the
Zap agent.

## Improve staged listings (catalog-listings skill)

`agent/skills/catalog-listings/SKILL.md` is a prompt/logic port of
commerce-agents `merchant-agent/skills/catalog-listings`: read the staged
record, write the weak or missing copy out in full, stage it, and let the owner
approve. It reasons over the same box `catalog.json` the Zaps write to, so it
works on whatever `merch-drop` / `event-ticket` staged.

Three Eve tools back it, and `zap listings` exposes the same logic in the box:

| Tool | CLI | Writes | Credentials |
| - | - | - | - |
| `search_listings` | `zap listings search [query] [--quality]`, `zap listings audit` | none | none |
| `get_listing` | `zap listings get <key>` | none | none |
| `stage_listing_update` | `zap listings update <key> --set field=value --note "..." [--live]` | catalog merge + `publish_catalog` | box gateway |

Guardrails (ported from `MerchantAgentConfig` / `check_guardrails`, sized to
air's `sanitizeCatalogItem`):

* Content fields only: `name` (≤ 200), `description` (≤ 2000), `kind`
  (`physical | digital | service | event_ticket`).
* `priceCents` and `inventory` are refused — re-run the Zap with new
  `PRICE_CENTS` / `INVENTORY` inputs. `key`, `imageUrl`, `active`, `source` are
  protected.
* ≤ 25 lines per change, one line per (listing, field) — keys compare
  case-insensitively, so `Show-Night` and `show-night` are one listing — a
  staging note is required, and `stage_listing_update` refuses a target the
  agent has not read with `get_listing` this session. The read is the whole
  content record: if `name`, `description`, or `kind` moved since the read,
  the stage is refused even when the edit touches a different field.
* Entries that air would refuse or strip at approval (bad `key`, `priceCents`,
  `inventory`, or an `imageUrl` off the media base) are reported as skipped and
  cannot be edited; a copy edit must never republish an entry that loses its
  image. Set `ZAP_AIR_MEDIA_BASE` when air runs with a non-default
  `R2_PUBLIC_BASE_URL`.
* Guardrails are re-checked under the catalog lock before the write; a
  violation stages nothing and calls air nothing.
* The lock spans the `publish_catalog` request. A refused publish rolls the
  edits back. A lost reply (timeout) is retried once — air reuses the pending
  `shop_publish` decision and does not repeat the note — and if the reply is
  lost again the edits are rolled back and the error (`COMMERCE_STAGE_TIMEOUT`)
  says air may already hold the decision; staging again converges on it.
  `payment_request` is never retried, because a repeat would file a second
  request: a timeout, a dropped connection, or a reply whose body never arrives
  is reported as possibly filed and is not retryable; check Needs You first.

`zap listings update` plans by default (prints the diff and the guardrail
result, exit 1 if blocked). `--live` merges the edit and POSTs
`{"action":"publish_catalog"}`, which files or refreshes the owner's
`shop_publish` decision; the storefront changes only after approval. Off-box,
both the CLI and the Eve tool fail closed with `COMMERCE_UNCONFIGURED` before
touching anything. Nothing in this flow charges.

## Write your own commerce agent

Follow `skills/zap-authoring/SKILL.md`, then end the recipe with a listing
step whose `image` names an earlier `image.*` step:

```yaml theme={"system"}
budget:
  estimate_usd: 0.05
  cap_usd: 1
steps:
  - id: product_art
    kind: image.gen
    prompt: prompts/product-art.md
    inputs: [user.image]
  - id: stage_listing
    kind: commerce.stage_listing
    inputs: [product_art]
    listing:
      kind: digital
      name: "{PRODUCT_NAME}"
      priceCents: user.PRICE_CENTS
      inventory: null
      image: product_art
```

`priceCents` and `inventory` accept a literal or a `user.<INPUT>` reference.
`image` must name an `image.*` step that appears above the listing step in
`Zap.md`, or a declared image input; `validate` rejects a reference to a later
step. Keep `budget.cap_usd` small: the only
spend is the art. Say in `SKILL.md` that the step stages for owner approval
and charges nothing.

Before shipping, run `npm run cli -- validate`, `npm run cli -- lint`,
`npm test`, and `npm run typecheck`.

## Invariants

* No Zap step ever charges or moves money. Every commerce result carries
  `charges: false`.
* Publishing is decision-gated. Nothing reaches `storefront_products` until the
  owner approves `shop_publish`.
* Prices are never client-supplied. `startCheckout` takes a key and a
  quantity; the amount comes from the projected row.
* Only R2-hosted images are projected. Everything else is dropped to `null`.

## What this does not touch

The box task router (`connectivity/taskrouter.py` in the `zap-light` and
`zap-heavy-*` templates) routes model calls for the box agent; commerce steps
call air's HTTP rails directly and never pass through it. Weight-level
training and self-improvement loops are out of scope for commerce agents; the
V10 spec in airv2 `goal.md` gates them behind a separate phase.
