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:
- 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.
- Payments (test mode).
POST /api/payments/{intent}/confirmhands 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. - Agent API.
POST /api/actionsexposes the same actions to outside agents. They're described in/agent.jsonand/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.
customCssis sanitised before it's applied:- The server drops
@importand 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,
contentand transforms aren't on it. - Rules are scoped under
#page, and the browser checks them again.
- The server drops
- 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 inprotected. 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_BPSbasis 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
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
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
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.