Sprout & Stem
by nickfabiano
README.md
# Sprout & Stem — a customer-service agent with layered guardrails
A working miniature of a production AI customer-service system, built as a learning
project: a fictional plant nursery whose support agent can look up orders, change
addresses, cancel orders, issue refunds, and escalate to a human — with every
dangerous capability constrained by **guardrails placed deliberately in the layer
where they can't be talked around**.
**Live demo:** the same agent is embedded in a mock storefront (`index.html` +
`api/chat.py`, deployed on Vercel). Try to talk it into a $75 refund — you can't.
## Architecture
```mermaid
flowchart LR
subgraph clients [Three MCP clients, one server]
A[Terminal agent<br/>agent/main.py]
B[Claude Code]
C[Web demo<br/>api/chat.py + index.html]
end
S[MCP server<br/>server/main.py<br/>6 tools + business rules]
D[(orders.json)]
A -->|MCP / stdio| S
B -->|MCP / stdio| S
C -->|direct import,<br/>same functions & schemas| S
S --> D
```
The business rules live in **one place** — the tool functions in `server/main.py`.
The terminal agent and Claude Code reach them over the MCP wire protocol (stdio);
the serverless web demo imports the same functions and derives its Anthropic tool
schemas from the same FastMCP registry. One source of truth, three clients.
## The guardrail hierarchy
| Guardrail | Layer | Why this layer |
|---|---|---|
| No payment data anywhere | **Data** | The strongest guardrail is data that doesn't exist — nothing to leak |
| Identity: email must match the order record | **Code** (tool) | The tool checks against ground truth; the prompt merely also asks |
| $50 single-refund cap | **Code** (tool) | Must survive any conversation; "the policy changed" can't change the code |
| Cumulative refunds ≤ order total | **Code** (tool) | Blocks double-dipping via installments |
| Max 2 refunds / customer / calendar month | **Code** (tool) | Frequency abuse; calendar month chosen for simplicity (known edge documented) |
| Refunds go only to the original payment method | **API shape** | The tool has no destination parameter — a capability not exposed is a rule that can't break |
| Confirm-before-write | **Harness gate** | A human keystroke/click approves every write; the model cannot produce that input |
| Tone, verification etiquette, when to escalate | **Prompt** | Judgment calls belong to the model; mechanical rules don't |
The design rule that fell out of building this: **a check is only as strong as the
trustworthiness of whoever supplies its input.** A `confirmed: true` tool parameter
would be filled in by the model — the untrusted party attesting to its own
compliance — so confirmation lives in the harness, where only a human can provide it.
Escalation (`escalate_to_human`) is deliberately **ungated and unverified**: never
add friction to the safety exit. It writes a full-context handoff file so the
customer never repeats themselves.
Every proposed write — approved or declined — is appended to an audit log
(`data/audit_log.jsonl`).
## Repository layout
```
server/main.py MCP server (FastMCP, stdio) — 6 tools, all business rules
agent/main.py terminal agent: Anthropic API loop + MCP client + confirmation gate
api/chat.py the same agent as a Vercel serverless function (gate → browser round-trip)
index.html mock storefront with the embedded chat widget
data/orders.json seed database: 20 fake orders, refund history
evals/ simulation-based eval harness (in progress)
tools.md the tool spec — written before any code, updated as decisions were made
LEARNING_LOG.md honest record of mistakes, corrections, and design lessons
```
## Run it
**Terminal agent** (needs an Anthropic API key, or OpenRouter's
Anthropic-compatible endpoint):
```bash
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
export ANTHROPIC_API_KEY=sk-ant-...
# or via OpenRouter:
# export ANTHROPIC_BASE_URL="https://openrouter.ai/api"
# export ANTHROPIC_AUTH_TOKEN="sk-or-..."
# export AGENT_MODEL="~anthropic/claude-sonnet-latest"
./.venv/bin/python agent/main.py
```
**As an MCP server for any client** (e.g. Claude Code):
```bash
claude mcp add sprout-stem -- "$PWD/.venv/bin/python" "$PWD/server/main.py"
```
**Web demo on Vercel:** import this repo, set the same env vars
(`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `AGENT_MODEL` — or just
`ANTHROPIC_API_KEY`), deploy. The demo database is a per-instance copy in `/tmp`,
so it self-resets on cold starts — a deliberate tradeoff for a stateless demo.
## Things that surprised me
See [LEARNING_LOG.md](LEARNING_LOG.md) — kept honestly, including the mistakes:
inventing tools that didn't exist, arguing against my own architecture, and
calling the strongest guardrail in the system "decorative."
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues