COUNTER

An agentic POS for one counter-service location.

Buyer agents and staff share one ticket engine. Models never mark a ticket paid.

For partners wiring a buyer agent · operators watching policy · anyone trying the sandbox.

  1. 01

    A buyer agent places an order

    Same checkout path a register would use, shaped for a machine—not a chat box scraping a screen.

  2. 02

    The store answers with recovery codes

    Out of stock, closed, needs approval. The agent can retry or substitute without guessing.

  3. 03

    A person decides when money needs judgment

    Caps, comps, refunds. Approval is a human control; paid is never written by the model or the browser.

Why money is safe

Exact invariant

paid and fired only inside applyPaymentSucceeded

Loading this location’s public policy…

Try this (~3 min)

  1. Open the glass — run the oat 86 recovery (structured out-of-stock → un-86 → ready → fake pay).
  2. Optional: Text to order — same rules from a third channel.
  3. Optional: For agents — discovery + curl happy path.

Ok if recovery is a code, not a 500. Detail: demo · oat 86.

Explain

Problem

Normal POS UIs aren’t safe for autonomous buyers. Agents need machine-readable policy and status—not a screen to scrape.

Counter

One store of record for a single location: catalog, 86, quote, tickets, ACP-shaped checkout, optional buyer MCP. Staff share the same tickets for policy decisions.

Not this

Not ChatGPT or Google certified. Not a full classic register. Sandbox payment is fake—no real money moves.

This page

Pitch and try path above. Notebook and wire contract below.

Who talks to whom

Buyer agent Checkout API / MCP
Counter Tickets + pay apply
Staff / kitchen Approvals · fire

Text to order is a third caller into the same engine.

Counter vs classic POS

For readers comparing to a traditional POS. Counter is an agent-facing store of record, not a skin on OSPOS. Buyer agents never call a classic POS API.

Role Counter Classic POS (e.g. OSPOS)
Catalog, 86, quote, tickets StoreAgent · SQLite · loc_main Items, mods, inventory DB
Buyer / agent checkout ACP-shaped HTTP · buyer /mcp · discovery Not designed for autonomous agents
Pay · paid · fired PaymentPort → applyPaymentSucceeded Sale rows, tenders, drawer
Staff surface glass, register, chat tools Register UI in the POS app
Policy (cap, hours) /.well-known/acp.json + session echo Store config, not agent-readable ACP

Today: no live link to a classic POS. No item sync, no shared register screen.

Future hook: after Counter pays, optionally push lines and totals into the merchant POS via PosPort.postSale—without marking paid again. Dev stand-in: text to order and the pos_sales fixture.

Flow: buyer agent → Counter (session, policy, pay) → optional post-sale → classic POS as downstream ledger.

Facts (wire level)

Wire-level rules the store already enforces.

Demos

Six walks against the live sandbox. Start with oat 86 on the glass.

1 · oat 86

who
ACP / buyer agent
time
~3 min
do
latte.12oz.oat → not_ready_for_payment + out_of_stock. Un-86 oat. Retry → ready_for_payment. fake_success → fired.
ok if
Recovery is structured (not 500)
open
register.html · sc 01–02

2 · over cap

who
risk / payments
time
~4 min
do
Cart > 2500¢ → pending_approval. Complete → 422. Manager approve session. Complete again.
ok if
Pay blocked until human
open
register.html · sc 06

3 · comp gate

who
staff copilot
time
~5 min
do
request_comp → approval row. Chat cannot approve. Manager button → re-quote.
ok if
Total moves only after decide
open
sc 07 · staff chat + Approve control

4 · webhook path

who
Stripe / reliability
time
~5 min
do
fake_processing → complete_in_progress. Inject or webhook → same apply. Replay idempotency key → no second PI.
ok if
Async path = sync path
open
sc 03 · 09 · POST /api/staff/inject_payment_succeeded

5 · close memo

who
ops
time
~2 min
do
F10 or POST /api/staff/close_day. Oat spans match events.
ok if
Spans ≠ prose summary
open
register.html · sc 13

6 · text to order

who
messaging / SMS later
time
~3 min
do
SKU / PAY on handset. Same rules. No paid shortcut from text UI.
ok if
Third caller, cashier constraints
open
phone.html
Operator notes

Tokens in .dev.vars.example. Amber keys: F5 agent · F2 un-86 · F8 approve · F10 close · G goal. Root token: counter. Smoke: bash scripts/smoke.sh · subset: npm run scenarios.

Plan · on the floor

Plan On the floor
One engine. No model in the money path. StoreAgent on loc_main, SQLite
src/store/agent.ts
Quotes, flattened SKUs, 86 as an event log. src/store/quote.ts
src/store/sku.ts
src/store/availability.ts
Policy a buyer agent can read without prose. GET /.well-known/acp.json
Same object on every session
src/store/policy.ts
ACP checkout and buyer MCP call the same functions. src/checkout/http.ts
src/checkout/mcp.ts
API-Version: 2026-04-17
paid and fired only inside applyPaymentSucceeded. src/store/payments.ts
Fake port, Stripe webhook, and synchronous complete all enter here. Dedup by event.id.
Idempotency on every POST. Same key + different body → 409
Complete replay → no second PaymentIntent
Quote at complete must match. Stale catalog → 409 total_mismatch
Client cannot set paid. validateStateChange throws on client paid writes
Staff may explain and file. They do not approve or mark paid. src/staff/chat-agent.ts; approval is a human control.
The demo stops being a cashier skin. public/register.js is BuyerPolicy and MerchantProposals.
src/register/ stays in the tree and is not the default.
Buyer recovers inside the discovered spend cap. Substitute on out_of_stock, halt on closed or over cap, complete only when ready and under buyer_auto_cap_cents.
Merchant files. Never settles. requestRefund and a suggested un-86; never decideApproval; close memo text stays code-rendered.

Scenarios

Thirteen fixtures. The list is the definition of done.

# Case Fixture
01 Oat out of stock on first quote → not_ready_for_payment / out_of_stock scenarios/01_oat_out_of_stock.json
02 Cashier un-86s oat → ready_for_payment, totals match scenarios/02_un86_ready.json
03 complete with fake_success → ticket fired; idempotent replay scenarios/03_idempotent_complete.json
04 Same idempotency key, different body → 409 scenarios/04_idempotency_conflict.json
05 Catalog bump between quote and complete → 409 total_mismatch scenarios/05_total_mismatch.json
06 Over buyer cap → pending_approval; manager UI approves scenarios/06_over_cap_hold.json
07 Comp request; chat cannot approve; manager button does scenarios/07_comp_approval.json
08 Refund approval gate; webhook moves amount_refunded scenarios/08_refund_approval.json
09 fake_processing + 86 + inject success → paid_after_86 scenarios/09_paid_after_86.json
10 Session TTL → expired scenarios/10_session_ttl.json
11 Client setState paid write rejected scenarios/11_reject_client_paid.json
12 Store closed → 201 + store_closed + policy scenarios/12_store_closed.json
13 Discovery file + close memo oat span scenarios/13_discovery_and_close_memo.json

For agents

Fake pay · loc_main · API-Version: 2026-04-17. Paths, headers, MCP, and a curl happy path.

Fake pay boundary

Sandbox uses PAYMENT_PORT=fake. Completing with fake_success does not move real money. Tickets still go paid/fired only through applyPaymentSucceeded.

Not a certified ACP merchant on ChatGPT: no request signatures, no delegated payment tokens, no platform outbound webhooks (Stage 2).

Paths

Replace loc_main with ?loc= / path id when multi-location is enabled.

Required headers (every POST)

Responses echo Idempotency-Key and Request-Id. Same key + different body → HTTP 409.

Session statuses

Message codes

unknown_item out_of_stock (+ since) store_closed invalid_quantity over_line_limit total_mismatch session_expired payment_declined already_completed approval_required

Create/update return 201/200 with bad lines kept on the session. Complete while not ready or pending approval → HTTP 422, no charge.

Buyer MCP tools

POST /mcp with the same buyer bearer. Staff tools are not registered.

Happy path (drip + fake pay)

BASE=http://localhost:5173
BUYER=buyer_dev_token
LOC=loc_main
VER="API-Version: 2026-04-17"
AUTH="Authorization: Bearer $BUYER"

# 1. discovery
curl -sS "$BASE/.well-known/acp.json"

# 2. create
RESP=$(curl -sS -X POST "$BASE/locations/$LOC/checkout_sessions" \
  -H "$AUTH" -H "$VER" -H "Idempotency-Key: demo-$(date +%s%N)" \
  -H "content-type: application/json" \
  -d '{"line_items":[{"id":"drip","quantity":1}]}')
echo "$RESP"
SID=$(python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" <<<"$RESP")

# 3. complete (fake — no real money)
curl -sS -X POST "$BASE/locations/$LOC/checkout_sessions/$SID/complete" \
  -H "$AUTH" -H "$VER" -H "Idempotency-Key: demo-c-$(date +%s%N)" \
  -H "content-type: application/json" \
  -d '{"payment_data":{"instrument":{"type":"fake","fake_code":"fake_success"}}}'

Cert harness: npm run agent-harness (server must be up). See README.

Example SKUs

Not in v1

github.com/b-lavania/counter