The Living Portfolio, explained from the ground up
A plain-English tour of every part of the system — the website, the AI that answers questions about Samuel, and the infrastructure underneath. Written for someone newer to software, but precise enough to defend to an engineer.
Two apps, two clouds, one push
The portfolio is two separate applications that live on two separate clouds, tied together by GitHub:
- The site (
living-portfolio) — a single hand-writtenindex.htmlof plain HTML/CSS/JavaScript, served by Vercel. - The API (
samuel-ai-api) — a Python FastAPI backend hosted on Render, which calls the Anthropic (Claude) API for answers, the OpenAI API for embeddings, and a Neon Postgres database for its knowledge store.
%%{init:{'theme':'base','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'ui-monospace, monospace','fontSize':'13px','primaryColor':'#e9f0ff','primaryBorderColor':'#3f74f5','primaryTextColor':'#17223a','lineColor':'#8794ab','secondaryColor':'#e4f6f2','tertiaryColor':'#f2f5fb'}}}%%
flowchart LR
Dev([You: git push]) --> GH[GitHub
source of truth]
GH -- rebuilds --> V[Vercel
the website]
GH -- rebuilds --> R[Render
the API]
User([Visitor's browser]) -- loads page --> V
User -- asks a question --> R
R -- embeds text --> O[OpenAI
embeddings]
R -- writes/reads vectors --> N[(Neon Postgres
+ pgvector)]
R -- generates answer --> A[Anthropic
Claude]
The words you'll need
The rest of the guide uses these terms constantly. Here's each one in a sentence — the full glossary is at the bottom.
- API
- A server that answers requests over the web. The browser sends JSON, the API sends JSON back.
- LLM
- A large language model (here, Anthropic's Claude) — the thing that writes the answers.
- RAG
- Retrieval-Augmented Generation: look up the relevant real documents first, then make the model answer using only those.
- Embedding
- Turning a piece of text into a list of numbers (a vector) that captures its meaning, so similar meanings land close together.
- Vector search
- Finding the stored text whose meaning-vector is closest to the question's — search by meaning, not keywords.
- Grounding
- Forcing the model to answer only from the retrieved sources, and to refuse when they don't cover the question.
- Agent
- An AI that works in a loop — it decides which tools to call, reads the results, and repeats until it can answer.
- MCP
- Model Context Protocol: a shared standard for offering those tools to any AI app (Claude Desktop, an IDE, another agent).
- Streaming (SSE)
- Sending a reply in pieces as it's written, so words appear live instead of all at once.
- Observability
- Being able to tell what the system is doing from the outside — request ids, timings, and live health numbers.
The journey of one question
When you type a question into the chat and hit send, here is every real step it takes — from the browser to the answer and back. Each later section zooms into one part of this.
%%{init:{'theme':'base','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'ui-monospace, monospace','fontSize':'13px','primaryColor':'#e9f0ff','primaryBorderColor':'#3f74f5','primaryTextColor':'#17223a','lineColor':'#8794ab','secondaryColor':'#e4f6f2','tertiaryColor':'#f2f5fb'}}}%%
flowchart TD
B([Browser POSTs the conversation]) --> C{CORS
allowed origin?}
C -- no --> X[Browser blocks it]
C -- yes --> M[Middleware: give it an ID,
start a timer]
M --> RL{Rate limit:
under 20 / 60s?}
RL -- no --> E429[429 Too Many Requests]
RL -- yes --> Q[Build query from
last 2 user turns]
Q --> EMB[Embed the question
OpenAI 1536-dim]
EMB --> VS[Top-5 vector search
Neon + pgvector]
VS --> G{Best match
≥ 0.35?}
G -- no --> REF[Refuse — no model call, $0]
G -- yes --> P[Ground Claude on the
numbered sources]
P --> CL[Claude Haiku 4.5
writes the answer]
CL --> FIN[Renumber citations,
strip markdown, price it]
FIN --> RESP([Reply + citations + cost + trace])
REF --> RESP
RESP --> LOG[Middleware: log JSON line,
record metrics, set X-Request-ID]
The two-gate shape is the heart of it: retrieval decides whether to answer at all, and only a strong enough match reaches the model. Everything else — the middleware, the rate limit, the trace — wraps around that core.
The centerpiece: answers grounded in real documents
The chat assistant doesn't answer from the model's general memory. It answers only from Samuel's own documents, cites its sources, and refuses when the documents don't cover the question. That is RAG, and it's the most important part to understand.
%%{init:{'theme':'base','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'ui-monospace, monospace','fontSize':'13px','primaryColor':'#e9f0ff','primaryBorderColor':'#3f74f5','primaryTextColor':'#17223a','lineColor':'#8794ab','secondaryColor':'#e4f6f2','tertiaryColor':'#f2f5fb'}}}%%
flowchart LR
subgraph Ingest [ Ingestion — run once, offline ]
direction TB
MD[corpus/*.md
6 markdown files] --> CH[Chunk: ~1000 chars,
150 overlap]
CH --> EM1[Embed each chunk
OpenAI 1536-dim]
EM1 --> DB[(Neon: documents
+ chunks + HNSW index)]
end
subgraph Ask [ Every question ]
direction TB
QQ[Question] --> EM2[Embed question]
EM2 --> SR[Cosine top-5
vs the HNSW index]
SR --> DB
end
DB --> GR{≥ 0.35?}
GR -- no --> RF[Refuse]
GR -- yes --> AN[Ground Claude → cited answer]
The corpus — knowledge as documents
/corpus folder of markdown: profile.md, skills.md, and four project files (cadence, consulting-platform, meridian, living-portfolio). These six files are the only things the bot can say. README.md files are skipped.Chunking — cutting documents into pieces
CHUNK_SIZE) with 150 characters of overlap (CHUNK_OVERLAP). The cut is nudged to the nearest newline or space so words aren't split mid-way.Embeddings — turning text into meaning
text-embedding-3-small, representing its meaning as a point in 1536-dimensional space where similar meanings land close together.Vector search — finding the closest chunks
RETRIEVAL_K = 5) most similar chunks by cosine similarity — the <=> operator gives cosine distance, and the SQL turns it into similarity with 1 - (embedding <=> query).vector_cosine_ops) makes "find the nearest vectors" fast without scanning every row. The query orders by the raw <=> operator specifically so that index stays usable.Grounding & the refusal threshold — the anti-hallucination gate
MIN_SIMILARITY), the bot refuses — "I don't have that in Samuel's documents" — without calling the model, so a refusal costs $0. Otherwise the retrieved chunks are handed to Claude with strict rules: answer only from these numbered sources, cite each claim with [n], never present in-progress work as finished.Citations you can touch
[n] markers are clickable; hovering shows the source's title and a snippet. Under the hood finalize_citations renumbers the model's markers to a clean 1..N that matches the deduplicated source list (several markers to one file collapse to one), attaches a 200-char snippet, and drops any marker pointing at a chunk that wasn't retrieved.[4] points at a chip that doesn't exist. Renumbering makes the visible numbers a real reference list.Conversation-aware retrieval
A follow-up like "what's it built with?" has no topic alone, so the query is built from the last two user turns (conversation_query, capped at 500 chars) — restoring the subject from the previous message.
Cost metering
Every answer reports real token counts × Haiku 4.5's price ($1/M input, $5/M output) — about $0.002 an answer, $0 for a refusal. Measured, never estimated.
Plain-text backstop
to_plain_text strips any ## or ** the model emits, because the widget renders plain text. The prompt asks for it; this guarantees it.
Persona vs. facts
about_me.py is now just voice and tone — no facts. All facts come from the corpus, so there is exactly one place to keep honest.
Measuring quality instead of guessing
"It seems to work when I try it" is not evidence. The evals system scores the assistant on a fixed set of questions with known-correct behaviour, so quality is a number, not a vibe.
The system reporting on itself
Every request gets a short id, is timed, and is logged as one line of JSON to stdout (which Render captures). An in-memory metrics registry tallies request counts, status classes, per-path counts, latency percentiles, and chat economics — always labelled with the process start time so the numbers are an honest "since this deploy" window, never a fake lifetime total.
| Piece | What it records |
|---|---|
X-Request-ID | A 12-char id on every response, matching the log line and the trace — so a report ("it broke at 2pm") ties to exact logs. |
| JSON logs | One object per line (ts, level, path, status, latency_ms, …) — greppable by machine, not human prose. |
/metrics | since uptime total_requests status_counts requests_by_path p50 / p95 chat: answers / refusals / tokens / cost |
The Trace | Per answer: grounded, sources, top_similarity, and real timings — retrieval_ms split into embed_ms + db_ms, plus model_ms. Operational data only — no message text, no secrets. |
retrieval_ms into embed_ms + db_ms revealed that, warm, the database connection (~1.4s) dominated the query — not the model. The fix: a connection pool that reuses a warm connection and validates it on checkout (so a connection Neon dropped while idle is replaced, not handed out dead). Measured live afterward: warm db time fell from ~1.7s to ~0.3s across a conversation. Reliability preserved, speed gained. That is the whole loop: measure → localize → fix → re-measure.An AI that uses tools in a loop
The chat answers one question in one shot. The agent is different: it's given a task plus a menu of tools, and it decides which to call, reads the results, and iterates until it can answer. That autonomous, multi-step tool use is the actual draw of agents.
%%{init:{'theme':'base','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'ui-monospace, monospace','fontSize':'13px','primaryColor':'#e9f0ff','primaryBorderColor':'#3f74f5','primaryTextColor':'#17223a','lineColor':'#8794ab','secondaryColor':'#e4f6f2','tertiaryColor':'#f2f5fb'}}}%%
flowchart TD
T([Task: 'Is he a fit for this role?']) --> LLM[Claude + the 4 tool schemas]
LLM --> D{Asked for
a tool?}
D -- yes --> RUN[Run it: search_portfolio /
list_skills / list_projects / list_services]
RUN --> FEED[Feed the result back]
FEED --> LLM
D -- no --> ANS([Final answer + every step it took])
LLM -. hard cap: 6 loops .-> ANS
search_portfolio (semantic search of the corpus), list_skills (shipped vs. building), list_projects, list_services (the real starting prices). The agent can look things up — it can never write, send, or buy anything.run_agent_stream, keeps calling Claude (model claude-haiku-4-5-20251001, up to MAX_ITERATIONS = 6). It records every thought and tool call and yields them as they happen; the non-streaming run_agent is a thin consumer of the same loop, so there's one behaviour to maintain.Publishing the tools to any AI client
The agent's four tools were useful but locked inside one endpoint. MCP (Model Context Protocol) is a shared standard — a universal plug — for offering tools to any AI app. mcp_server.py wraps the same four functions and publishes them over MCP with FastMCP, so Claude Desktop, an IDE, or another agent can use them.
agent.py implementations — two surfaces, one set of tools, so a change to a tool changes it everywhere.python mcp_server.py as a local subprocess and talks over its input/output pipes. A hosted HTTP version can also mount at /mcp on the API, but it's off by default behind the ENABLE_MCP_HTTP flag so it can't affect the live API unless deliberately turned on.Watching the answer type itself
Instead of waiting for the whole answer, /chat/stream sends it in pieces using Server-Sent Events — one long HTTP response where each message is data: {…}. The model's text arrives as token events as it's written, then a final done event carries the finished reply, citations, cost, and trace. /agent/stream does the same with step events (each thought or tool call) then a done.
Cache-Control: no-cache and X-Accel-Buffering: no and then measuring live confirmed tokens arrive incrementally (spread over the model's writing time), not in one blob.The backend — a typed API
A Python FastAPI app. Every request and response is a typed Pydantic model, so a malformed request is auto-rejected with a 422 and the exact field errors before any handler runs — and the interactive docs at /docs generate from those same types. It exposes eight routes (plus the optional /mcp):
| Route | Does |
|---|---|
GET /health | Liveness check — returns {"status":"ok"}. |
GET /version | The deployed commit SHA (from Render's RENDER_GIT_COMMIT) + when the process started — so "is my change live?" is a fact, not a guess. |
GET /metrics | The live metrics snapshot (counts, latency, running cost) since this deploy. |
POST /inquiry | The contact form — validated, honeypot-guarded, stored in Neon. (details below) |
POST /chat | The RAG assistant. rate-limited |
POST /chat/stream | The RAG assistant, streamed token-by-token. rate-limited |
POST /agent | The tool-using agent. rate-limited |
POST /agent/stream | The agent, streaming each step. rate-limited |
CORS — the origin gate
The API only accepts browser calls from an allowlist (CORS_ORIGINS, defaulting to just the production site). A non-matching origin gets no access-control header and the browser blocks it. It's why saving the page to your desktop breaks the chat: file:// isn't on the list.
/inquiry — validation + honeypot
Pydantic's EmailStr rejects a bad email with a 422. A hidden website field (a honeypot) that humans never see: if it's filled, a bot did it, so the API returns success without storing. No database configured → 503, never a fake success, and it never leaks the driver error.
Rate limiting — protecting the paid endpoints
The chat and agent endpoints each cost real money per call and are open to the internet, so a script could run up a bill. A thread-safe sliding-window limiter caps each client IP at 20 requests per 60 seconds (both tunable by env var). Over the cap, the server returns 429 Too Many Requests with a Retry-After header. A blocked request isn't recorded, so a persistent caller recovers as its window slides rather than locking itself out. A normal visitor stays far under the cap; a script hits it.
The frontend — no framework, on purpose
The whole site is one hand-written index.html: HTML for structure, CSS custom properties (--accent, --bg, …) as a design-token system, and vanilla JavaScript for behaviour. No React, no build step. The only heavy library is three.js, lazy-loaded and only for optional 3D layers that are flagged off in production — so a normal visitor downloads none of it.
build (the roadmap + a deployment topology), xray (an architecture x-ray of one request), evals, metrics (reads /metrics live), agent (a live agent you can give a task), services, inquiry. Four of them — xray, evals, metrics, and agent — are each gated by a feature flag so a capability can flip on the week its module ships; build, services, and inquiry always render./chat/stream and reads the Server-Sent Events, typing the answer in live, then rendering the touchable citation chips and the cost footer from the closing event. It picks its API base automatically — localhost:8000 in dev, the Render URL in production.Two interactive explainers on the site are worth calling out: the Architecture X-Ray traces one request through its real layers (the system in time), and the Deployment Topology shows the six services and their connections (the system in space) — including the one push that fans out to two clouds, which a linear request trace can't express.
Deployment — one push, two clouds
There are no manual deploy steps. A git push to main triggers continuous deployment: Vercel rebuilds the site and Render rebuilds the API, in parallel, independently. Each half can ship or roll back on its own.
%%{init:{'theme':'base','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'ui-monospace, monospace','fontSize':'13px','primaryColor':'#e9f0ff','primaryBorderColor':'#3f74f5','primaryTextColor':'#17223a','lineColor':'#8794ab','secondaryColor':'#e4f6f2','tertiaryColor':'#f2f5fb'}}}%%
flowchart LR
P([git push main]) --> GH[GitHub]
GH --> V[Vercel
rebuilds the site]
GH --> R[Render
rebuilds the API]
R -.->|reads secrets at runtime| S[[ANTHROPIC / OPENAI /
DATABASE_URL — never baked in]]
The precise wording matters and is deliberate: the site says continuous deployment (automated deploy on push), not "CI/CD" — the automated test-gate in CI is a later module and isn't claimed yet. That's an honesty distinction, not a nitpick.
The last two — both shipped, each earned differently
Two deployment artifacts live in the repo. The honesty rule is that a green "Shipped" badge means built and verified — and both have now earned it, but the verification looked different for each.
Docker Shipped
A production Dockerfile (single python:3.12-slim stage, dependency-layer caching, a non-root appuser, a /health HEALTHCHECK) plus docker-compose.yml. Verified end to end: docker compose up --build builds the image, the container comes up healthy (its HEALTHCHECK passes), and GET /health returns {"status":"ok"}.
Terraform Shipped
A terraform/ config codifying the whole stack (Render + Neon + Vercel) across the three real providers, with secrets as sensitive variables. Verified: terraform validate passes (and caught a real attribute bug), all three live resources were terraform imported into state, and terraform plan round-trips clean (“No changes.”) against the running infra — so Terraform manages the real stack. No apply touched production: the pre-existing resources were adopted via import, not created.
docker build proved it — not a step before. Terraform stayed amber (“authored & validated”) until the live stack was actually imported and terraform plan round-tripped clean — only then did it earn green. "Shipped" is earned by verification, and only by verification.The test suite — where honesty becomes a test
62 tests across seven files, covering the pure logic without needing the network or a paid API: chunking, the grounding/refusal decision, citation renumbering, the plain-text strip, the conversation query, cost math, the metrics registry, the rate limiter, the agent's tools and loop (driven by a fake client), and the MCP server (over a real in-memory client↔server session).
The honesty system — the thesis of the whole thing
One principle runs through every part: shipped work and in-progress work are always explicit, and nothing is claimed that can't be defended. It shows up everywhere — the corpus is audited before ingestion; the bot refuses rather than guesses; every metric names its window; the agent's prompt forbids calling building work "shipped"; the roadmap distinguishes three states; and tests fail if a claim outruns reality.
The module roadmap
The system was built in public, one module at a time. Where each stands today:
| # | Module | What it added | Status |
|---|---|---|---|
| 01 | RAG with citations | Answers grounded in real documents, with sources | Shipped |
| 02 | Evals | 108-case golden dataset, harness + LLM judge (calibration pending) | Shipped |
| 03 | Observability | Request ids, JSON logs, /metrics, the trace, connection pooling | Shipped |
| 04 | Agents | A tool-using agent loop over the portfolio data | Shipped |
| 05 | MCP server | The same tools over the Model Context Protocol | Shipped |
| 06 | Streaming | Token-by-token responses over SSE | Shipped |
| 07 | Docker | A production Dockerfile + compose — image builds & runs healthy | Shipped |
| 08 | Terraform | The stack as infrastructure-as-code — imported; terraform plan round-trips clean against live | Shipped |
Plus hardening that isn't a numbered module: rate limiting (shipped) and an opt-in hosted MCP endpoint (deployed dormant behind a flag).
Glossary — say these correctly
- RAG
- Retrieval-Augmented Generation: retrieve relevant documents, then have the model answer using only them.
- Embedding
- A list of numbers (a vector) representing a piece of text's meaning. Here, 1536 numbers from OpenAI's
text-embedding-3-small. - Vector / dimensions
- Each text becomes a point in 1536-dimensional space; nearby points mean similar things.
- Cosine similarity
- How aligned two vectors' directions are. pgvector's
<=>gives cosine distance; similarity is1 − distance. - Chunk
- A ~1000-character slice of a document that gets embedded and retrieved (with 150 chars of overlap).
- pgvector
- A Postgres extension adding a
VECTORtype and similarity search. - HNSW
- The index that makes nearest-vector search fast without scanning every row; builds incrementally, no training.
- Grounding
- Constraining the model to answer only from provided sources.
- Hallucination
- The model confidently making something up; grounding + a refusal threshold is the defense.
- Top-k
- Retrieving the k best matches (here, k = 5).
- Threshold
- The minimum similarity (0.35) below which the bot refuses.
- Agent
- An AI that loops — decides a tool, runs it, reads the result, repeats — up to 6 times here.
- Tool
- A specific action the AI can trigger; here four read-only lookups over the corpus and service list.
- MCP
- Model Context Protocol — a standard way to offer tools to any AI client.
- stdio
- Talking over a program's standard input/output pipes instead of a network port.
- SSE
- Server-Sent Events — a one-way stream of
data: {…}messages, used to send tokens live. - Middleware
- Code that wraps every request — here it ids, times, logs, and records each one.
- CORS
- A browser rule about which sites may call an API; only the portfolio's origin is allowed by default.
- Rate limiting
- Capping requests per client IP in a window (20 / 60s), returning 429 when exceeded.
- Connection pool
- Reusing warm database connections instead of reconnecting per request; here validated on checkout.
- Continuous deployment
- A push to
mainauto-deploys — no manual step. (CI/CD adds an automated test-gate, not yet claimed.) - Infrastructure-as-code
- Describing servers/databases in text files (Terraform) instead of clicking dashboards.