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.
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.
02
The store answers with recovery codes
Out of stock, closed, needs approval. The agent can retry or
substitute without guessing.
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
Payment success is applied in one server path.
Staff chat can request comps and refunds but cannot approve or mark
paid.
The browser cannot set a ticket to paid.
Exact invariant
paid and fired only inside applyPaymentSucceeded
Loading this location’s public policy…
Open the glass
Notebook
Text to order
For agents
acp.json
Try this (~3 min)
Open
the glass
— run the oat 86 recovery (structured out-of-stock → un-86 → ready →
fake pay).
Optional:
Text to order
— same rules from a third channel.
Optional:
For agents
— discovery + curl happy path.
Ok if recovery is a code, not a 500. Detail:
demo · oat 86 .
try
explain
pos
facts
demos
plan / floor
scenarios
for agents
not in v1
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.
Discovery + every session echo policy (cap, hours,
timezone).
86 = append event. out_of_stock carries
since.
Over buyer_auto_cap_cents →
pending_approval. Complete → 422 until manager.
Chat: request_comp / request_refund. No
decide_approval tool.
paid_after_86 on board. No auto-refund in v1.
Close memo: code from SQL. Not LLM.
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)
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
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
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
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
Discovery: GET /.well-known/acp.json
Create:
POST /locations/loc_main/checkout_sessions
Update:
POST /locations/loc_main/checkout_sessions/{id}
Get:
GET /locations/loc_main/checkout_sessions/{id}
Complete:
POST /locations/loc_main/checkout_sessions/{id}/complete
Cancel:
POST /locations/loc_main/checkout_sessions/{id}/cancel
Buyer MCP: POST /mcp (same bearer)
Replace loc_main with ?loc= / path id when
multi-location is enabled.
Authorization: Bearer <BUYER_BEARER>
API-Version: 2026-04-17
Idempotency-Key: <unique>
content-type: application/json when body present
Responses echo Idempotency-Key and
Request-Id. Same key + different body → HTTP 409.
Session statuses
incomplete — no lines
not_ready_for_payment — 86, closed, bad qty, unknown
item
ready_for_payment — clean quote, under cap (or
approved)
pending_approval — over
buyer_auto_cap_cents
complete_in_progress — PaymentIntent processing
completed — webhook/sync applied
canceled · expired
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.
get_menu
get_store_status
create_checkout
update_checkout
get_checkout
complete_checkout
cancel_checkout
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
drip — always available in seed
latte.12oz.whole / latte.12oz.oat — oat
starts 86’d (seed event)
burrito — 3× exceeds default 2500¢ cap
Not in v1
3DS · ACP request signatures · platform webhooks to ChatGPT / UCP
certification
Card-present · tips · split tender · multi-location · inventory
counts · live price edits
Model marks paid · model picks SKUs or writes memo · auto-approve ·
x402 as lunch tender
OSPOS live link · Twilio SMS · photo vision · public
/inbox without auth
github.com/b-lavania/counter