An AI agent that processes or denies e-commerce refunds — and cannot be talked into an unauthorized one, because authorization is enforced in deterministic code, not in the prompt.
- Layer A — LLM (LangGraph + Claude Haiku): understands intent, runs the conversation, decides which tools to call, communicates. It proposes.
- Layer B — deterministic policy engine + tools: computes the refund verdict from data + rules and re-validates server-side before any money moves. It ratifies or rejects.
A jailbroken model can call issue_refund(amount=99999) all it wants — the policy engine rejects it.
Stack: FastAPI + LangGraph + Claude Haiku 4.5 · Postgres (app data + LangGraph checkpoints) · React + Vite + Tailwind SPA · browser-native voice mode.
- uv (Python package manager — fetches Python itself)
- Node.js 20+ (for the frontend)
- Docker (for the local Postgres) — or any reachable Postgres
- An Anthropic API key (
ANTHROPIC_API_KEY)
# 1. Clone
git clone https://github.com/rishavnaskar/refund-agent.git
cd refund-agent
# 2. Configure env
cp .env.example .env # then put your ANTHROPIC_API_KEY in .env
# DATABASE_URL already defaults to the local docker Postgres
# 3. Install deps (backend venv via uv + frontend via npm)
make install
# 4. Start the local Postgres (docker) and seed it
make db-up # starts postgres:16 on localhost:5432
make seed # creates schema + loads the demo CRMmake api # terminal 1 — backend on http://localhost:8000
make web # terminal 2 — frontend on http://localhost:5173
# open http://localhost:5173 (Customer Chat + Admin tabs)make api runs ensure_seeded() on boot, so the schema + demo data are created automatically if the database is empty.
make db-up # tests need Postgres running
make test # deterministic policy + red-team suites (no API key needed)Tests run against TEST_DATABASE_URL if set, else DATABASE_URL, else the local docker Postgres. If no Postgres is reachable they skip with a clear message rather than failing.
| Command | What it does |
|---|---|
make db-up / make db-down |
start / stop the local Postgres (docker) |
make seed |
(re)create schema + reseed the demo data |
make test |
run the policy + red-team test suites |
make api / make web |
run backend / frontend |
make clean |
stop Postgres and wipe its volume |
| Var | Required | Default | Notes |
|---|---|---|---|
ANTHROPIC_API_KEY |
✅ | — | Claude API key |
DATABASE_URL |
✅ | local docker Postgres | Postgres connection string |
MODEL |
— | claude-haiku-4-5 |
Anthropic model id |
BASIC_AUTH_USERNAME / BASIC_AUTH_PASSWORD |
— | demo / unset |
set a password to gate a public deploy |
ALLOWED_ORIGINS |
— | * |
restrict CORS for a split deploy |
RETURN_WINDOW_DAYS, ESCALATION_THRESHOLD |
— | 30, 500 |
policy thresholds |
Never commit .env — it's gitignored. .env.example is the template.
State lives in an external Postgres, and the app builds into a single Docker image that serves both the API and the React SPA (one URL, no CORS). It must run as one always-on instance (the SSE engine keeps per-thread queues in memory) — don't add replicas or extra workers.
- Create a project at supabase.com.
- Project → Connect → Session pooler and copy the connection string (use the Session pooler — IPv4, port
5432, supports the prepared statements the checkpointer needs). - That string is your production
DATABASE_URL. The app creates its tables automatically on first boot.
The repo has a single-service Dockerfile (builds the SPA, then runs the API). Pick a host that runs an always-on container with HTTPS:
Google Cloud Run (free tier; scale-to-zero)
gcloud run deploy refund-agent \
--source . \
--region <your-region> \
--allow-unauthenticated \
--min-instances 0 --max-instances 1 \
--set-env-vars "ANTHROPIC_API_KEY=...,DATABASE_URL=...,BASIC_AUTH_PASSWORD=..."--max-instances 1 keeps the single-instance guarantee. (Store real secrets in Secret Manager rather than --set-env-vars for anything long-lived.)
Render (one-click blueprint)
The included render.yaml defines the service. Render → New → Blueprint → pick the repo, then set ANTHROPIC_API_KEY, DATABASE_URL, and BASIC_AUTH_PASSWORD in the dashboard.
Set BASIC_AUTH_PASSWORD so the public URL can't run up your Anthropic usage. Share the URL with the demo username and that password.
backend/
app.py # FastAPI app: lifespan (seed + Postgres checkpointer), SPA serving, basic-auth
config.py # model id, DATABASE_URL, policy constants, SUPPORT_CONTACT
db.py # Postgres helpers (psycopg3 + connection pool)
data/ # schema.sql, policy.md, seed_crm.json, seed.py
agent/ # graph.py, nodes.py, prompts.py, summarize.py, llm.py, tools.py
gateway/ # routes_chat.py, routes_admin.py, runner.py (SSE), events.py
tools/ # crm.py, policy_engine.py, refunds.py, rules.py, types.py
trace/ # store.py (reasoning traces)
tests/ # test_policy.py, test_redteam.py
frontend/ # React + Vite + Tailwind SPA (chat, admin, voice)
docker-compose.yml # local Postgres
Dockerfile # single-service production image (SPA + API)
render.yaml # Render blueprint