Say what you want and what you'll pay. Approve once with your passkey. An agent watches the price and the stock, and buys the moment your conditions are met — unattended, inside a cap enforced by the card network rather than by our code.
Live: capia-ai.netlify.app ·
API capia.onrender.com ·
MCP https://capia.onrender.com/mcp
You describe a purchase in your own words — "the titanium wallet on ridge.com if it drops under $50, at most $60 all-in". Capia compiles that into a typed rule, shows you real products with live prices, and asks you to approve a spending limit with your passkey. That approval mints a Prava mandate: a standing authorization Visa itself enforces, scoped to one store and capped at the number you approved.
Then you close the laptop. A watcher polls the merchant, and when your condition fires it quotes the real total, checks it against the cap, charges the mandate, and attempts the purchase. Every attempt — bought, refused, missed, failed — lands in a ledger with the reason and the merchant's own error code.
The design claim is about what the agent cannot do. It can create watches, pause
them, cancel them and lower a limit. It cannot approve a mandate, raise a limit, change
your delivery address, or spend a cent. Those are not permission checks that could be
bypassed — the tools simply do not exist, and the cap is enforced by Visa rather than by
an if statement in this repository.
| Step | State |
|---|---|
| Natural language → typed rule | works |
| Store discovery ("who sells a mattress?") | works |
| Live catalogue, real prices, real variants | works |
| Binding quote incl. tax and shipping | works |
| Mandate approved by passkey, network-enforced | works |
| Charge against the mandate, Visa credential minted | works |
| Drawdown and settlement reporting | works |
| Merchant checkout completion | blocked — see below |
The last step is refused by the merchant, not by us. Shopify's UCP complete_checkout
returns -32000 AuthenticationRequired because Capia is not a Shopify-signed agent.
That refusal is captured, reported to Prava as DECLINED, and shown in the ledger with
the code.
This is not a Ridge quirk. We probed 91 well-known brands: 50 serve a live UCP manifest, and not one advertises a payment capability. The wall is agent identity, and it is the whole ecosystem's, not ours. Prava's Browser Harness is the intended way through it; driving it end to end gets as far as the merchant's checkout and is then stopped by Shopify's bot captcha.
So: everything up to the merchant's door is real, including money moving against a real mandate. The door is shut to unaccredited agents today.
Every surface passes the user's words through verbatim and lets Capia compile them. None of them can approve spending.
- Web chat — describe it, pick from real products, approve with a passkey
- Voice — speak instead of typing; the transcript lands in the composer for you to read before it is sent, because speech recognition is allowed to be wrong but not silently wrong
- MCP — any agent (Claude Code, Codex, Cursor, VS Code, Windsurf, Zed) over streamable HTTP with a scoped, revocable token
- WhatsApp — inbound message → watch, entirely inside the 24-hour service window so no message templates are needed. Backend complete and tested; not currently surfaced in the UI.
Prava — payments. Mandates are created per watch, scoped to the actual store, and capped at exactly the amount approved. Charging mints a single-use Visa network credential; Capia never holds a reusable card number and stays out of PCI scope. Charges are reported back so the lifecycle closes — a correctly reported decline releases its drawdown.
UCP (Shopify's Universal Commerce Protocol) — discovery, catalogue and quoting
across 23 verified stores. "Verified" means two things: the domain serves a live
/.well-known/ucp manifest and a real catalogue search returns priced, in-stock
variants.
Senso — store discovery. A knowledge base of store profiles built from observed catalogue data plus each merchant's own description of itself, so "I need a mattress" finds Casper even though the word "mattress" appears nowhere in Casper's product titles. Grounded or silent: an answer without citations is discarded, and "none of our stores sell that" is a first-class answer.
OpenAI — the intent compiler (structured outputs) and speech-to-text.
cp .env.example .env # works unedited; add keys when you need them
docker compose upPostgres, migrations, API on :8000, web on :3000. Verified from a destroyed volume —
migrations run at container start, so a clean clone comes up with a correct schema in one
command.
| Web | http://localhost:3000 |
| API | http://localhost:8000 |
| API docs | http://localhost:8000/docs (non-production only) |
| Health | http://localhost:8000/health |
Use localhost, never 127.0.0.1 — WebAuthn binds to an RP ID and an RP ID can never
be a bare IP.
Faster than rebuilding an image on every edit. Postgres still comes from compose.
docker compose up -d postgres
cd backend
python3 -m venv .venv && ./.venv/bin/pip install -e ".[dev]"
cp .env.example .env
./.venv/bin/alembic upgrade head
./.venv/bin/uvicorn app.main:app --reload --port 8000
./.venv/bin/pytest # 286 passing
./.venv/bin/ruff check app tests alembicTests need their own database — createdb capia_test, or:
docker exec capia-postgres-1 psql -U capia -d postgres \
-c "CREATE DATABASE capia_test OWNER capia;"Schema changes:
./.venv/bin/alembic revision --autogenerate -m "what changed"
./.venv/bin/alembic upgrade headcd frontend && pnpm install && pnpm devNeeded once for discovery, and again whenever the store list changes. Re-running refreshes in place rather than duplicating.
cd backend && ./.venv/bin/python -m app.knowledge.seedSign in, open Assistants, pick your client, and copy the one command it gives you. Or by hand:
claude mcp add --transport http capia https://capia.onrender.com/mcp \
--header "Authorization: Bearer cap_..."Twelve tools: propose_watch, confirm_watch, list_stores, find_stores,
browse_store, check_approval, list_watches, get_watch, pause_watch,
resume_watch, cancel_watch, lower_limit.
There is deliberately no tool to raise a limit and none to approve a mandate.
Tokens are minted from your signed-in session, shown once, stored only as a SHA-256 digest, and revocable from the same page.
backend/
app/
config.py all settings; nothing else reads os.environ
core/
database.py engine, Base, TimestampMixin, get_session
security.py bcrypt, access/refresh JWT, opaque token digests
money.py integer minor units; floats rejected at the boundary
registry.py Registry[T] — providers and fulfillers by name
signals.py SignalProvider, Reading
conditions.py edge-triggered conditions
fulfillment.py Fulfiller, AuthorizationRef, Quote, Order
policy.py PolicyGate — pure, deterministic spend rules
engine.py one tick: observe → decide → quote → charge → record
watcher.py the loop (FOR UPDATE SKIP LOCKED, heat tiers, jitter)
intent/ natural language → typed watch rule
prava/ Prava REST client
providers/ UCP catalogue + signal provider
fulfillers/ UCP fulfiller
knowledge/ Senso client and the store-profile seeder
channels/ WhatsApp Cloud API client
mcp_server/ MCP server, PAT auth middleware
services/ business logic — routers stay thin
api/ routers
alembic/ migrations
frontend/ Next.js 16, React 19, Tailwind 4
Three ways into one account, because how you prove identity is deliberately separate from who you are:
- Password — bcrypt, access + refresh JWT with rotation, email verification, reset
- Google — authorization code + PKCE, exchanged server-side
- Passkey — WebAuthn, enrolled from an existing session as an additional method
Adding Apple or GitHub later is a row in auth_identities, not a schema change.
Worth knowing:
- Refresh tokens rotate. Presenting an already-rotated token is treated as theft: every session for that user is revoked, and the revocation is committed before the error is raised, because the request session rolls back on exception.
- Only digests are stored for refresh, verification, reset and agent tokens.
- Login is constant-ish time and returns an identical error for an unknown email and
a wrong password.
/auth/forgot-passwordresponds identically either way. - Token types are not interchangeable. A refresh token presented as a bearer token is rejected.
- Federated email linking only happens when the provider verified the email.
- Password reset revokes every existing session.
Type hints everywhere. ruff clean, line length 100. Async throughout — no blocking IO
in request paths. Config only via app.config.Settings. Money as integer minor units
with Decimal confined to adapter boundaries; never float.
PRAVA_LIVE defaults false, is required to construct anything that can spend real money,
and is reported by /health so a mistake is visible in one request.
Third-party surfaces fail open where they inform (discovery degrades to the full store list) and closed where they authorize (an unsigned webhook is refused outright).
Built before the hackathon window opened (Aug 1, 07:30 IST):
- Project scaffolding, dependency setup, lint/test configuration, Dockerfiles and
docker-compose.yml - The full identity layer above — password, Google OAuth, passkeys, refresh rotation, verification, reset — with 32 tests
- Postgres schema and the initial Alembic migration
- Landing and auth pages, from a design generated before the window
- Throwaway API reconnaissance scripts (not in this repo, not imported by it) used to learn Prava's REST and MCP surfaces before writing the client
Built during the window: the entire watch engine — signal providers, conditions, the policy gate, the Prava client and fulfiller, the watcher loop, the intent compiler — the MCP server, the Senso discovery layer, voice input, the WhatsApp channel, and all product UI beyond sign-in.