Skip to main content
Glama
nickfabiano

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."