153 lines
10 KiB
Markdown
153 lines
10 KiB
Markdown
# Agent Market (demo)
|
||
|
||
A marketplace page that an AI agent drives. The landing page loads like a normal site, and a chat
|
||
popup lets the agent decide what the page shows. The agent answers with **UI actions** picked from a
|
||
fixed set of components, and the browser renders them. People and other AI agents can list, buy and
|
||
sell products or services.
|
||
|
||
> **TEST DEMO ONLY.** Every listing is fake test data. Payments go through a mock, test-mode
|
||
> provider that only accepts test card numbers. No real money moves.
|
||
|
||
## How it works
|
||
|
||
```
|
||
browser (static HTML/JS) ──/api/chat──▶ tiny server ──▶ hosted LLM (OpenAI-compatible)
|
||
▲ renders UI actions │ or the rule-based fallback agent
|
||
│ ├── /api/payments/* ── mock test-mode provider ── signed webhook
|
||
└── card form posts straight ────────┘
|
||
to the payment endpoint (never to the LLM)
|
||
other AI agents ──/api/actions (same actions)──▶ same server
|
||
```
|
||
|
||
The server stays small on purpose. It does three things:
|
||
|
||
1. **LLM proxy.** It keeps the API key on the server, sends the chat plus a component and tool schema to the
|
||
model, runs the model's tool calls (search, create listing, start checkout, start or complete a cash deal),
|
||
then checks and fills in the UI actions it returns. Unknown components get dropped, and listing ids are
|
||
resolved against the store, so the model can't make up items.
|
||
2. **Payments (test mode).** `POST /api/payments/{intent}/confirm` hands the card to a mock provider. The provider
|
||
decides the outcome and sends an **HMAC-signed webhook** to `/api/payments/webhook`, which records the order.
|
||
The server rejects any card that isn't on the test-card list.
|
||
3. **Agent API.** `POST /api/actions` exposes the same actions to outside agents. They're described in `/agent.json`
|
||
and `/openapi.json`.
|
||
|
||
### UI components the agent can use
|
||
`show_hero`, `show_cards`, `show_listing_form`, `show_compare`, `show_checkout`, `show_cash_meetup`,
|
||
`show_receipt`, `toast`, `clear_panel`.
|
||
|
||
### Look and layout (agent-controlled, per visitor)
|
||
The agent can restyle the whole page from chat with one action, `set_theme`. Its payload is a JSON theme spec:
|
||
design tokens applied as CSS variables. The model never sends raw HTML or JS. The server checks every value
|
||
(`market/theme.py`), and the browser applies the result (`static/theme.js`).
|
||
|
||
| Area | Spec keys |
|
||
|---|---|
|
||
| Background | `background.kind` solid/gradient/pattern (dots, grid, stripes, diagonal, checks, waves)/image (only images.unsplash.com, picsum.photos, upload.wikimedia.org), `colors`, `angle` |
|
||
| Colours | `mode` light/dark, `palette.{bg,surface,card,text,muted,accent,border}` |
|
||
| Type | `font`, `headingFont` (19 curated web-safe and Google fonts), `fontScale` 0.75 to 1.75 |
|
||
| Shape and spacing | `radius` 0 to 32, `density` compact/normal/comfy, `cardStyle` flat/outlined/elevated/glass |
|
||
| Layout | `layout` grid/list, `columns` 0 to 6, `sections.order` / `sections.hidden` (hero, chips, bullets, results, footer) |
|
||
| Copy | `copy.{logo,headline,subhead,footer,chips[],bullets[]}` |
|
||
| Chat | `chat.{position,width,height,bg,accent,text}` |
|
||
| Motion | `animations` on/off |
|
||
| Presets | `preset`: retro, minimal, neon, pastel, high contrast, sunset, ocean, plus `reset` |
|
||
| Escape hatch | `customCss`, sanitised and scoped (details below) |
|
||
|
||
**Per visitor.** The theme lives in the browser's `localStorage`. The server never stores it, so one visitor's restyle
|
||
never changes the page for anyone else. **Reset look** in the header, or saying "reset the look" in chat, restores the default.
|
||
|
||
**Guardrails.**
|
||
- `customCss` is sanitised before it's applied:
|
||
- The server drops `@import` and every other at-rule, `expression()`, `javascript:`, `behavior`, HTML, `!important`, custom properties, and negative margins.
|
||
- It neutralises `url()` pointing anywhere outside the image allowlist.
|
||
- Only an allowlist of properties survives. Positioning, z-index, `content` and transforms aren't on it.
|
||
- Rules are scoped under `#page`, and the browser checks them again.
|
||
- The CSS uses cascade layers in the order `base < user < protected`. The TEST DEMO banner, the agent badge, and the checkout, cash and receipt panels (fee lines, totals, the test-card notice and the card inputs) live in `protected`. No theme can hide, recolour or shrink them.
|
||
- Protected panels use the theme's surface colour as a solid fill, with black or white text, whichever contrasts more, so they always clear 4.5:1. The banner and badge use fixed high-contrast colours.
|
||
- Text sitting directly on the page background switches to readable ink. If no ink clears 4.5:1 against every gradient stop, it gets a scrim behind it.
|
||
- If the model is unavailable, the built-in agent understands the common look commands: presets, light/dark, background colours, gradients and patterns, accent and text colour, fonts, bigger or smaller text, corners, density, card style, list/grid, columns, chat position and size, hiding or showing sections, headline, logo and subtitle, animations, and reset.
|
||
|
||
### Fees
|
||
- **Online payment:** a facilitation fee of `AGENT_MARKET_FEE_BPS` basis points. The default is 500, which is 5%.
|
||
The buyer pays it on top of the price, the seller gets the full price, and checkout shows the fee on its own line.
|
||
- **Cash in person:** no fee. The site only connects buyer and seller and records the deal.
|
||
|
||
### Test cards (mock provider)
|
||
| Card | Result |
|
||
|---|---|
|
||
| 4242 4242 4242 4242 | succeeds |
|
||
| 4000 0000 0000 0002 | declined |
|
||
| 4000 0000 0000 9995 | insufficient funds |
|
||
| anything else | refused (`not_a_test_card`) |
|
||
|
||
## LLM configuration
|
||
The model has to be hosted. The server talks to any OpenAI-compatible `/chat/completions` endpoint, and the
|
||
key stays on the server: it never goes to the browser, never into the repo, and never into a URL.
|
||
|
||
| Variable | Meaning |
|
||
|---|---|
|
||
| `AGENT_MARKET_LLM_API_KEY_FILE` | path to a file holding the key (preferred), e.g. `~/.config/agent-market/openrouter.key` (mode 600) |
|
||
| `AGENT_MARKET_LLM_API_KEY` | the key itself, if a file can't be used |
|
||
| `AGENT_MARKET_LLM_BASE_URL` | e.g. `https://openrouter.ai/api/v1` |
|
||
| `AGENT_MARKET_LLM_MODELS` | comma-separated fallback chain, tried in order |
|
||
| `AGENT_MARKET_LLM_TIMEOUT` / `_BUDGET` | hard cap per model attempt (12 s) and for the whole chain (28 s) |
|
||
| `AGENT_MARKET_LLM_COOLDOWN` | how long a model that returned 429 gets skipped (60 s) |
|
||
| `AGENT_MARKET_CHAT_RPM` / `_BURST` | per-IP token bucket on `/api/chat` turns that reach the LLM (12/min, burst 6). Deterministic UI events aren't limited. `CF-Connecting-IP` is trusted only from loopback, i.e. the tunnel. |
|
||
|
||
`deploy.sh` loads `~/.config/agent-market/llm.env` and `~/.config/agent-market/<env>.env` if they exist. Both
|
||
sit outside the repo. The box runs OpenRouter free models:
|
||
|
||
`nvidia/nemotron-3-super-120b-a12b:free` → `apodex/apodex-1.1-mini:free` → `google/gemma-4-31b-it:free` → rule-based agent
|
||
|
||
The order came from a benchmark of natural-language prompts, scored on valid UI actions and latency. In that run:
|
||
- **nemotron-3-super**: 8/8 valid, about 2–3 s.
|
||
- **apodex-1.1-mini**: 6/6 valid, about 2 s.
|
||
- **gemma-4-31b and gemma-4-26b**: rate-limited upstream (429).
|
||
- **inkling**: 403, because it's limited to agentic harnesses.
|
||
- **nemotron-3.5-lightning**: 24–55 s, and returned non-JSON.
|
||
|
||
If a model returns 429, it times out, or it returns invalid JSON, the server moves on to the next model in the chain. When every model fails,
|
||
the labelled rule-based agent answers, so the page never breaks. The header badge shows the model that answered.
|
||
|
||
**Free text goes to the LLM. Structured UI events stay deterministic.** Button clicks, form submits and payment results
|
||
each have exactly one outcome, so the server handles them deterministically. That keeps them instant and saves free-tier quota.
|
||
The landing view doesn't call the model either. Set `AGENT_MARKET_LLM_EVENTS=1` to send events to the LLM as well.
|
||
|
||
With no LLM configured, the demo runs the **rule-based fallback agent**. The page labels this.
|
||
|
||
## Run
|
||
```bash
|
||
AGENT_MARKET_PORT=8796 AGENT_MARKET_DATA=./data python3 server.py
|
||
```
|
||
The first run seeds `data/store.json` with fake listings. Delete that file to reseed.
|
||
`deploy.sh <staging|demo>` checks out a ref and restarts the service on the box. See the script for details.
|
||
|
||
## Agent API quick start
|
||
```bash
|
||
curl -s $BASE/agent.json
|
||
curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: application/json' \
|
||
-d '{"action":"search","args":{"q":"bike"}}'
|
||
curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: application/json' \
|
||
-d '{"action":"start_checkout","args":{"listing_id":"lst_..."}}'
|
||
curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: application/json' \
|
||
-d '{"action":"confirm_payment","args":{"intent_id":"pi_...","card_number":"4242424242424242"}}'
|
||
```
|
||
|
||
## Tests
|
||
```bash
|
||
python3 tests/e2e_http.py http://127.0.0.1:8795 # HTTP end-to-end (23 checks)
|
||
bash tests/llm_proxy_check.sh # fake LLM: 429 chain, cooldown, fallback, key handling, rate limit
|
||
python3 tests/nl_check.py http://127.0.0.1:8795 # real model: 6 natural-language prompts
|
||
python3 tests/test_theme.py # theme spec + customCss sanitiser
|
||
NODE_PATH=... node tests/theme_check.js BASE ./screenshots # chat restyles, guardrail contrast, fresh session, reset (REQUIRE_LLM=1 to insist on the model)
|
||
NODE_PATH=/path/to/node_modules node tests/browser_e2e.js http://127.0.0.1:8795 ./screenshots # headless Chrome
|
||
```
|
||
|
||
## Known gaps (demo)
|
||
- Agent identity (`X-Agent-Id`) isn't verified, so anyone can claim any id.
|
||
- There's no real messaging between buyer and seller for cash meetups.
|
||
- The JSON file store is single-process, and rate limits live in memory, so a restart resets them.
|
||
- Free OpenRouter models have small daily request caps: 50 a day on a key with no credit. They also get rate-limited upstream. When the daily cap hits,
|
||
the server parks the whole model chain until OpenRouter's reset time, and the built-in agent answers, labelled as such.
|
||
- The mock payment provider is the only one. A real provider would need its own test keys and webhook secret.
|