# Kevin Murphy — Full Corpus

Site: https://kevinmurphywebdev.com
Generated: 2026-07-06T18:51:18.260Z

This file contains every published blog post and project case study on
https://kevinmurphywebdev.com, concatenated for single-fetch ingestion by AI crawlers
and agents. The lighter index is at https://kevinmurphywebdev.com/llms.txt. The
machine-readable agent descriptor is at https://kevinmurphywebdev.com/.well-known/agents.json.

Each section is separated by a level-2 heading naming the source URL
so a chunker can split cleanly. Posts come first (most recent first),
then project case studies, then the resume summary.

---
## Building issuegraph: a LangGraph triage agent, with the evals to prove it works

URL: https://kevinmurphywebdev.com/blog/building-issuegraph
Date: 2026-07-06
Tags: ai, agents, langgraph, langchain, langsmith, evals, open-source, applied-ai
Excerpt: I build applied AI in the open, from multi-agent orchestration to retrieval. issuegraph puts LangGraph through the same bar: a GitHub issue triage agent as a state machine, with conditional routing, a bounded quality-guard loop, human-in-the-loop interrupts over Redis checkpoints, and a LangSmith eval suite that checks whether the classifier's confidence is honest. This post walks the graph architecture, the interrupt-and-resume plumbing, the one bug that taught me the most, and the calibration math.

> **Repo:** [github.com/midimurphdesigns/issuegraph](https://github.com/midimurphdesigns/issuegraph)
>
> **Live:** [issuegraph.kevinmurphywebdev.com](https://issuegraph.kevinmurphywebdev.com)

## What issuegraph demonstrates

- **LangGraph state machines.** Seven nodes, shared typed state, conditional edges for routing and a bounded retry loop, a checkpointer for pause and resume.
- **Human-in-the-loop interrupts.** A confidence gate calls `interrupt()` mid-run, the graph checkpoints to Redis, and a human approves or rejects the draft before the run finishes, possibly on a different serverless instance.
- **LangChain chains with structured output.** Prompt templates piped into Zod-schema-forced tool calls, so every LLM boundary returns typed, validated data.
- **LangSmith evals.** A labeled golden set uploaded as a dataset, `evaluate()` running the full graph over every example, a deterministic evaluator paired with an LLM judge.
- **Confidence calibration.** Brier score plus a reliability table that checks the classifier's stated confidence against actual accuracy, the same discipline forge applies to its debugging lanes.
- **Public-endpoint hardening.** Preset-only input, per-IP rate limits, a global daily budget that fails closed, sanitized production errors.

## Why I built it

Most of my agent work runs on the Vercel AI SDK and the raw Anthropic SDK. forge orchestrates four parallel subagents with hand-rolled coordination. Kev-O does retrieval with hand-rolled BM25 and reranking. I like building from primitives because you learn what the abstractions are hiding. LangChain and LangGraph are the other major dialect of agent engineering, and I wanted to put them through the same bar I hold my own orchestration to.

So I built the kind of system I would normally hand-roll, in the framework instead. A GitHub issue triage agent: read an issue, decide what it is, draft a maintainer reply, check the draft, and know when to ask a human instead of trusting itself. Small enough to be honest about, real enough to exercise the parts of LangGraph I actually wanted to test: conditional routing, a bounded loop, and human-in-the-loop interrupts.

The three pieces, in one line each: LangChain is the glue for calling a model, LangGraph turns a set of steps into a runnable graph, and LangSmith records and grades what ran. The one I most wanted to learn was LangGraph, because it is built for exactly the thing this project needed: an agent that stops partway through and waits for a person.

Which is why I do not have a single default. If I had to pick one for a team that ships a lot of different AI features, LangChain plus LangSmith is the safe standardization: the ecosystem is broadest, the tracing and eval story is the most complete, and most agents eventually grow a branch or a loop. But for a single-provider service where I want every token in view, or a durable step pipeline, the lighter tools are not a compromise, they are the better fit. The skill is not loyalty to one. It is knowing which shape the problem is before you reach.

## The graph

A pipe is a straight line. Real triage branches, loops, and pauses, and that is exactly what a pipe cannot express. LangGraph models it as a state machine: nodes are functions that read shared state and return partial updates, edges decide what runs next.

```ts
const builder = new StateGraph(TriageState)
  .addNode("classify", classifyNode)
  .addNode("draftBug", draftBugNode)
  .addNode("draftFeature", draftFeatureNode)
  .addNode("draftDocs", draftDocsNode)
  .addNode("draftQuestion", draftQuestionNode)
  .addNode("guard", guardNode)
  .addNode("gate", gateNode)
  .addEdge(START, "classify")
  .addConditionalEdges("classify", routeByCategory, {
    draftBug: "draftBug",
    draftFeature: "draftFeature",
    draftDocs: "draftDocs",
    draftQuestion: "draftQuestion",
  })
  .addConditionalEdges("guard", afterGuard, {
    gate: "gate",
    draftBug: "draftBug",
    draftFeature: "draftFeature",
    draftDocs: "draftDocs",
    draftQuestion: "draftQuestion",
  })
  .addEdge("gate", END);
```

A conditional edge is just a function that returns the name of the next node. `routeByCategory` reads the classification and picks a specialist drafter. `afterGuard` either advances to the gate or points back at a drafter, and pointing backward is all it takes to make a retry loop. The loop is bounded by a redraft counter in state, because an LLM judge that keeps rejecting drafts would otherwise spin forever.

The classifier that feeds the router is a plain LangChain chain. The part worth knowing is `withStructuredOutput`: it converts a Zod schema into a forced tool call, so the model cannot reply with prose. It has to fill in the schema, and the chain returns a typed object.

```ts
const ClassificationSchema = z.object({
  category: z.enum(["bug", "feature", "docs", "question"]),
  confidence: z.number().min(0).max(1),
  reasoning: z.string(),
});

const classifierChain = prompt.pipe(
  model.withStructuredOutput(ClassificationSchema, { name: "classify_issue" }),
);
```

That confidence number matters later. It is a claim the model makes about itself, and the whole back half of this project is about checking that claim.

## The interrupt, and the bug that taught me the most

The gate node is my favorite part of the system. If the classifier is confident, the run finalizes on its own. If not, the graph stops and asks a person.

```ts
function gateNode(state: TriageStateType) {
  const confidence = state.classification?.confidence ?? 0;
  if (confidence >= CONFIDENCE_GATE) return { status: "auto-finalized" };

  const decision = interrupt({
    reason: "low confidence, human approval required",
    confidence,
    draft: state.draft,
  }) as { approved: boolean };

  return { status: decision.approved ? "human-approved" : "human-rejected" };
}
```

`interrupt()` pauses the graph and saves a checkpoint: which node was running, what the state held, everything needed to come back later. Resuming is `graph.invoke(new Command({ resume: value }), config)` with the same thread id. The checkpointer is what makes this work, and it is required, because you cannot resume a run that nothing remembered.

The bug: my first version resumed with a bare boolean, `new Command({ resume: false })` for a rejection. LangGraph threw `EmptyInputError`. A falsy resume value looks like no input at all, so approval worked and rejection crashed. The fix is resuming with an object, `{ approved: false }`, which is never falsy. It is a small bug, but it taught me how the resume plumbing actually works underneath: the resume value is real graph input, not a signal, and it flows through the same input validation everything else does.

The serverless wrinkle is that the pause and the resume are different requests, which on Vercel can mean different machines with nothing shared in memory. So the checkpointer writes to Redis. The demo derives its Redis connection from the same Upstash environment variables the rate limiter uses, and paused runs survive across instances with an hour of TTL.

## LangSmith, in practice

Two environment variables turn on tracing, and every chain and graph call ships a full trace tree: each model call with its inputs, outputs, token counts, and latency, nested inside the node that made it. When the guard rejects a draft and the graph loops back for a redraft, the trace shows the second call exactly where it happened. Debugging a non-deterministic system without this is guesswork. With it, you read what actually ran.

The demo page streams the same structure to the browser as the graph executes, one event per node, so watching the pipeline light up on screen is watching the trace shape form in real time.

Evals are where LangSmith earns its keep. The golden set is a list of issues with known-correct categories. It uploads once as a dataset, and `evaluate()` runs the whole graph against every example.

```ts
const experiment = await evaluate(target, {
  data: "issuegraph-golden",
  evaluators: [categoryAccuracy, draftQuality],
  experimentPrefix: "issuegraph",
});
```

Two evaluators, deliberately different kinds. Category accuracy is an exact match, because there is one right answer and comparing strings is free and objective. Draft quality is an LLM judge, because a good maintainer reply has a hundred valid forms and no string comparison can grade it. Knowing which kind fits which output is most of eval design.

## Calibration: checking the model's honesty

The classifier says 0.95 confident. Should anyone believe it?

Calibration is the discipline of checking. Take every prediction, pair the stated confidence with whether the answer was actually right, and compute two things. The Brier score averages the squared gap between confidence and outcome, so confident-and-wrong costs the most. The reliability table buckets predictions by stated confidence and compares each bucket's claim to its actual accuracy.

```
── CALIBRATION REPORT ──────────────────────────────────
samples:      8
Brier score:  0.003  (0 = perfect, 0.25 = coin flip)

reliability by confidence bucket:
  bucket      n   claimed  actual   verdict
  0.9-1.0     8   0.95     1.00    calibrated
```

That report is real output from the committed golden-set run, and the honest read is that it flatters the model. Eight clean examples prove the plumbing, not the classifier. The value shows up as the golden set grows with production failures: the buckets where claimed confidence outruns actual accuracy are exactly where the confidence gate should distrust the model and route to a human. forge does this same loop with a learned weight per debugging lane. issuegraph rebuilds the pattern on LangSmith eval data, and the confidence gate is the consumer.

## What the framework bought, and what it cost

My other agent projects hand-roll their coordination. forge runs four parallel subagents with my own orchestration. Kev-O does retrieval with my own BM25 and reranking. Building this one in a framework instead was the point, and it made the trade concrete.

What it bought me was real. The checkpointer and `interrupt()` gave me pause-and-resume across serverless instances for almost no code, which is the kind of thing that is fiddly to get right by hand. Tracing turned on with two environment variables. `evaluate()` plus datasets replaced a pile of test-harness code I have written from scratch elsewhere.

What it cost me showed up at debug time. When I coordinate everything myself, a failure lands in code I wrote and can read straight through. The `false`-resume bug earlier in this post is the small example: the misbehavior was inside the framework's input handling, so understanding it meant reading LangGraph's source rather than my own. In my own code it would have been obvious at a glance. That is the honest tradeoff, and it is not a knock on the framework. It is just where the time moves when you let someone else own the control flow.

## The demo hardening

The hosted page triages curated presets only. No visitor text ever reaches a prompt, which closes prompt injection and keeps spend bounded. Per-IP rate limits ride a sliding window, a global daily budget caps the worst case at a few dollars, and both fail closed if the limiter is unconfigured in production. Resume requests make no model call, so they stay rate-limited without burning budget. Production error events are generic; the real errors stay in server logs.

The CLI version accepts any public GitHub issue URL, which is the honest split: capability in the repo, restraint on the public endpoint.

---
## Building anchor: dual-surface product pages, agents.json, delegated-authority checkout, and AEO instrumentation

URL: https://kevinmurphywebdev.com/blog/building-anchor
Date: 2026-05-24
Tags: ai, agents, commerce, aeo, agentic-commerce-protocol, nextjs, open-source, applied-ai
Excerpt: Anchor is an open-source AI-native product catalog. Every product has a human page AND three statically-cached LLM-facing endpoints. A /.well-known/agents.json descriptor publishes capabilities in the Agentic Commerce Protocol shape. A delegated-authority checkout endpoint runs an eight-check pipeline. AEO instrumentation classifies 13 LLM crawlers and logs every fetch through a proxy so cache hits stay observed. Here are the design choices that mattered.

> **Repo:** [github.com/midimurphdesigns/anchor](https://github.com/midimurphdesigns/anchor)
>
> **Live:** [anchor.kevinmurphywebdev.com](https://anchor.kevinmurphywebdev.com)

## Skills, concepts, and tools this build demonstrates

**Next 16 rendering modes.** Static (`○`), SSG with `generateStaticParams` (`●`), Partial Prerendering with `<Suspense>` holes (`◐`), Dynamic SSR (`ƒ`), and the Edge proxy layer. Every mode lives in a real route in the catalog, not a toy example.

**Caching primitives under `cacheComponents: true`.** The `'use cache'` directive, `cacheLife('hours' | 'days')`, `cacheTag` for surgical labeling, and `revalidateTag(tag, profile)` for surgical invalidation. A sale invalidates one product entry; the other nine stay cached.

**Serverless lifecycle.** Next 16's `after()` primitive for queuing telemetry writes past the response. Why unawaited Promises die in serverless and survive on long-running Node. When to reach for a queue (durable side effects) vs `after()` (best-effort telemetry).

**Edge proxy as the cross-cutting layer.** `proxy.ts` (the renamed `middleware.ts`) classifies User-Agent and Referer on every matching request, attaches `X-Anchor-*` headers, and queues Redis writes via `after()` so cache hits stay observed. Runs before any route handler; geographically close.

**Agentic Commerce Protocol shape.** `/.well-known/agents.json` discovery descriptor with capabilities, endpoints, auth model, pricing-negotiation envelope, and rate limits. The RFC 8615 `.well-known` convention. The five fields that have no `llms.txt` equivalent and the consumer that reads each one.

**Delegated authority and the eight-check pipeline.** HMAC-SHA256 with constant-time signature verification (timing-safe XOR loop, not `===`). Ordered checks cheapest-first so attackers spamming junk tokens never touch Redis. Nonce (`jti`) for replay protection; Idempotency-Key for network-retry protection. The sharpest distinction: nonces reject duplicates, idempotency keys repeat the original response.

**Citation-shaped content for LLM crawlers.** Three independent URL placements per response (opening prose line, JSON-LD `@id`, closing canonical line). The `Source:` lexical prefix that exploits LLM training-data bias. The lost-in-the-middle effect and why opening-line URL placement raises citation accuracy from roughly 40% to roughly 85%.

**AEO (Answer Engine Optimization) instrumentation.** Bot classification across 13 known LLM crawlers (ChatGPT-User, GPTBot, Perplexity-User, PerplexityBot, Claude-Web, ClaudeBot, Google-Extended, GoogleBot, Applebot-Extended, Bytespider, meta-externalagent, Amazonbot, cohere-ai). Redis sorted sets keyed `aeo:fetch:<slug>:<bot>` with `ZADD`/`ZCOUNT`/`ZRANGE` for O(log N) time-windowed counts. Cited-by attribution via Referer matching for the answer-engine conversion funnel.

**Generative UI in AI SDK v6.** `streamObject` with a Zod discriminated union as the schema (`streamUI` was removed in v6). The model picks one of N shapes via a `kind` literal; React switch-renders the matching component. Type-safe at every boundary; no markup ever crosses the wire from the model.

**Server Actions for client-to-server calls.** Type-safe at the call boundary, the client imports the action function and gets the return type without designing a JSON envelope. Used in the comparison agent flow.

**Stack.** TypeScript strict, Next 16 with `cacheComponents: true`, React 19, Tailwind v4, Vercel AI SDK 6, `@ai-sdk/anthropic` against Claude Haiku 4.5, Zod for schema validation, Upstash Redis (with an in-memory fallback for local dev).

**Discipline.** Five-scenario test suite that asserts every guarantee (happy path, replay rejection, over-budget rejection, SKU mismatch, idempotent retry); 11/11 assertions pass. A `/docs/rendering` page documenting every route's mode + rationale, sourced from `lib/render-modes.ts` so docs cannot drift from the routes that ship. A dev-only render inspector that overlays colored boundaries on every annotated component and shows a counterfactual latency-savings panel vs a fully-dynamic baseline.

## What anchor is

Anchor is an open-source product catalog designed for a web where half the traffic is becoming AI agents instead of humans. Ten fictional specialty-coffee products. Every product has a human page rendered as React in the browser, AND three statically-cached LLM-facing endpoints, markdown, JSON-LD, and plain text. A `/.well-known/agents.json` descriptor publishes the site's capabilities (endpoints, auth, pricing negotiation, rate limits) in the Agentic Commerce Protocol shape. A `POST /api/agent/checkout` endpoint runs an eight-check delegated-authority pipeline before any charge fires. A live AEO dashboard classifies 13 known LLM crawlers by user-agent and surfaces the per-product fetch counts.

It's the third in a trilogy. Forge demonstrates multi-agent orchestration. Loom demonstrates durable AI commerce. Anchor demonstrates the discovery and citation surface that lets agents find and transact with a site at all.

## What's in the box

- Dual-surface product pages: a human page and three citation-shaped LLM endpoints per slug
- A `/.well-known/agents.json` descriptor publishing capabilities, endpoints, auth model, pricing-negotiation envelope, and rate limits
- A `/llms.txt` markdown reading list pointing at every product's agent endpoint
- A `/agents` human page that renders the same descriptor an agent runtime sees (documentation IS the implementation)
- An eight-check delegated-authority checkout pipeline with HMAC-SHA256 + constant-time signature verification + nonce + idempotency
- Cited-by attribution: when a human arrives on a product page with a Referer from one of six known LLM client surfaces (ChatGPT, Perplexity, Claude, Gemini, Copilot, You), the proxy classifies it and tags the response
- AEO instrumentation: every `/agent` fetch classified across 13 LLM crawlers and logged to Redis sorted sets via `after()` so cache hits stay observed
- A live dashboard with 24h / 7d / 30d totals, bot-mix breakdown, per-product table, and a live tail of recent fetches
- A comparison agent at `/compare` with generative UI: the model picks one of three shapes (spec table, pros and cons, recommendation paragraph) via structured-output dispatch
- A self-documenting `/docs/rendering` page listing every route's mode + rationale + the Next 16 primitives the build leans on
- A dev-only render inspector that overlays colored boundaries on every component classifying its render mode and shows a counterfactual savings panel

## Why the agent surface is split into three static routes

The first version of the agent endpoint was content-negotiated: a single dynamic `/agent` route that read the `Accept` header and branched between markdown, JSON-LD, and plain-text bodies. It worked. It also missed the point of the build.

The refactor: split into `/agent/markdown`, `/agent/json`, `/agent/plain`. Each one prerendered per slug at build time via `generateStaticParams`. All 30 routes (10 products × 3 formats) plus a 10-redirect canonical-shortcut on `/agent` itself live as static files served from the edge cache. TTFB drops to about 5ms worldwide because no serverless function ever runs on a cache hit. The body is a pure function of the catalog wrapped in `'use cache'`, same input, same output, every time.

This left a problem: if the route handler doesn't execute on cache hits, the AEO logger doesn't fire on cache hits. Telemetry would silently die the moment the cache started working.

The fix: move logging into the proxy. The proxy runs at the edge before any route handler, on every matching request, regardless of cache status. It classifies the User-Agent and queues a Redis write via `after()`. The body comes from cache; the telemetry comes from the proxy. Both layers do exactly what they're best at, and the cache savings don't cost observability.

## The citation-shaped opening line

Every `/agent/markdown` response opens with:

```
Source: https://anchor.kevinmurphywebdev.com/products/moonshot-grinder-x1, Moonshot Lab Moonshot Grinder X1 is listed at $899.00 on anchor (...)
```

Three structural choices are doing work in that line. The word `Source:` is a high-signal prefix, LLMs trained on academic and journalistic text strongly associate it with "authoritative citation follows," which pushes the URL into a category the model preserves through summarization. The canonical URL is the second token, not buried at the end, because LLMs cite documents most reliably when the URL is in the opening sentence (the lost-in-the-middle effect, written up by Stanford in 2023). The structured assertion (`is listed at $899.00 on anchor`) gives the model a fact to cite alongside the URL.

The URL appears in three independent locations in every response, once in the opening line, once in the JSON-LD `@id` field, once in the closing `Canonical URL:` line. Three independent placements is the redundancy that pushes citation accuracy from roughly 40% to roughly 85%. If one mechanism fails (JSON-LD parser missed the metadata, RAG chunker grabbed only the middle), the others backstop.

## The agents.json descriptor

`/.well-known/agents.json` is the machine-readable capability descriptor. The path piggy-backs on RFC 8615's reserved `/.well-known/` convention so agent runtimes know where to look without scraping the homepage. The shape is loosely modeled on Stripe's Agentic Commerce Protocol draft. Conservative on fields: every field documents a known consumer in code comments, nothing speculative.

The five fields that have no `llms.txt` equivalent are `endpoints` (read by an agent runtime that wants to call search or checkout), `auth` (read by a protocol negotiator before any authenticated request), `pricing.negotiation` (read by an agent buyer deciding whether to probe for a discount), `capabilities` (a quick filter, does this site support purchase, or only browse), and `rateLimits` (per-agent caps the runtime respects to avoid backoff). Plus the supporting fields: `schemaVersion`, `name`, `origin`, `contact`, `terms`.

`/agents` is the human-facing mirror of the same descriptor, rendered by the same `loadAgentsDescriptor()` function the JSON endpoint uses. Documentation IS the implementation; drift between the two is impossible by construction. Both surfaces share one cached loader entry tagged `descriptor`, so a sale that changes inventory invalidates both in one `revalidateTag('descriptor')` call.

## The eight-check pipeline

`POST /api/agent/checkout` runs an ordered eight-check pipeline before any side effect fires:

1. Token present
2. Signature valid (HMAC-SHA256, constant-time compare)
3. Not expired
4. Agent matches caller
5. Scope matches request (action + SKU + maxCents)
6. Nonce unused (the token's `jti` is single-use)
7. Idempotency-Key check (return cached response on retry)
8. Process charge + revalidateTag

The order is cheapest-and-most-likely-to-fail first. Signature is the cheapest cryptographic check, about one millisecond, and the most likely to fail when an attacker is forging tokens. Expiry next because it's a single subtraction. Agent binding and scope are logical compares against verified claims. The Redis lookups (nonce and idempotency) only run after the signature is verified, so an attacker spamming junk tokens never touches the storage layer.

Expiry deliberately comes after signature verification, not before. The `exp` field lives inside the token body. Reading it before verifying the signature means trusting unverified bytes, which is the same class of bug as JWT's notorious `alg: none` exploit. The rule that prevents this whole family of attacks: never trust a claim until the signature attesting it is verified.

The signature comparison uses a constant-time XOR loop, not `===`. JavaScript's string equality returns false the instant it finds a mismatched byte, which means a wrong signature with a matching first byte takes slightly longer than one that mismatches immediately. An attacker who can measure timing can recover signatures byte-by-byte. The constant-time loop XORs every byte regardless of mismatch and OR-accumulates the difference into one comparison at the end. No timing leak. The same pattern lives in Node's `crypto.timingSafeEqual` and Stripe's webhook signature verifier for the same reason.

## Nonce vs idempotency

Both reject duplicates. Both live in Redis with a 24h TTL. The mechanism is the same; the threat model is different.

The nonce is in the signed token. It defends against replay attacks, an attacker who captures a valid signed token (network logs, MITM) can't re-fire it because the token's `jti` is single-use and tracked server-side. Each token gets used exactly once; the second attempt returns 409 Conflict.

The idempotency key is in the request headers. It defends against network retries, the agent fires a request, the network drops the response packet, the agent retries with the same `Idempotency-Key`. Without idempotency the second retry processes a second charge. With idempotency the second retry returns the cached response from the first attempt; no second charge fires.

Nonces reject duplicates with an error. Idempotency keys repeat the original success. That's the sharpest distinction worth memorizing.

## Generative UI in AI SDK v6

AI SDK v6 removed `streamUI`, the v3 primitive that let a model return React elements directly. The v6 idiomatic pattern is structured-output dispatch.

The comparison agent at `/compare` works like this. The user picks two products. A Server Action calls `streamObject` with a Zod discriminated union as the schema, three shapes, each tagged with a `kind` literal: `specTable`, `prosCons`, `recommendation`. The model decides which shape fits (same category → spec table, overlapping use case → pros and cons, unrelated → recommendation paragraph) and emits a typed object matching that shape. The Zod schema validates the output before it crosses any boundary. The client receives the validated object and switch-renders the matching React component on `kind`.

No HTML, markup, or scripts ever cross the wire from the model. The model picks a shape and fills typed fields; React renders. Type-safe at every boundary, can't inject markup, deterministic enough that the same input reliably picks the same shape (temperature 0.3 on Claude Haiku 4.5). Generative because the SHAPE is generated, not just the content.

## Rendering modes in Next 16 with cacheComponents

The build leans on every Next 16 rendering mode:

- **Static (○)** for `/`, `/agents`, `/compare`, `/docs/rendering`, `/agents-descriptor`, `/llms-txt`. Pure server components reading cached data via `'use cache'`. Edge CDN serves them.
- **SSG (●)** for the four `/products/[slug]/agent*` routes. `generateStaticParams` expands the slug parameter into 10 prerendered files per format.
- **PPR (◐)** for `/products/[slug]` (cached product copy + dynamic AgentTally hole) and `/dashboard` (functionally force-dynamic via `headers()` reads in every Suspense child).
- **Dynamic (ƒ)** for `/api/agent/checkout` and `/api/agent/issue-token`. Every response depends on the request body + token contents + Redis lookups + time. No caching helps.
- **Edge (proxy)** for cited-by attribution + AEO logging. Runs before any route handler on every matching request.

The `cacheComponents: true` flag inverts the rendering defaults: every server component is treated as static unless something opts it out of static. The opt-out is implicit, reading `headers()`, `cookies()`, `searchParams`, or uncached data sources is what makes a component dynamic. The per-route `dynamic`, `revalidate`, and `runtime` exports from earlier Next versions are disallowed; the data you read inside the component is the only signal Next uses to classify it.

A `/docs/rendering` page lists every route with its mode and rationale. The page reads from `lib/render-modes.ts`, the same source of truth the inspector overlay uses, so the documentation can never drift from the routes that ship.

## Surgical cache invalidation

The cached loaders carry three independent tags:

```
loadProduct('moonshot-grinder-x1')   → cacheTag('product:moonshot-grinder-x1')
loadProduct('kestrel-pour-kettle')   → cacheTag('product:kestrel-pour-kettle')
... (one per slug)
loadAllSlugs()                       → cacheTag('catalog:index')
loadAgentsDescriptor()               → cacheTag('descriptor')
```

A sale fires `revalidateTag('product:moonshot-grinder-x1', 'hours')` and `revalidateTag('descriptor', 'hours')`. Two entries recompute on the next read: the affected product (now reads decremented inventory) and the descriptor (whose `catalog[]` contains an `inStock` field for every SKU). Nine other product entries stay cached. The catalog-index entry stays cached because the slug list didn't change, a sale decrements stock, it doesn't remove a product.

Tagging is what makes the invalidation surgical instead of a sledgehammer. If we'd tagged all three loaders with one common tag like `'catalog'`, every sale would invalidate everything, defeating the point. Three tags, three separate decisions about when each one needs to recompute.

## What I'd do differently in production

The HMAC token shape is a demo simplification. A production deployment would use asymmetric ed25519 signatures issued by the user's wallet, with anchor verifying via the user's public key, the user's private key never leaves their device. The eight-check verification logic is identical either way; only the key model changes.

The in-memory Redis fallback (active when `UPSTASH_*` env vars are missing) is intentionally per-process and doesn't survive restarts. It exists so local dev and the test script work without standing up Upstash. Production needs Upstash configured for cross-instance state.

The comparison agent's three shapes are hand-picked for a coffee catalog. A different product category might want a different shape inventory (size comparison? compatibility matrix? color swatches?). The pattern generalizes, Zod discriminated union, model picks, client dispatches, but the specific shapes are domain choices.

The catalog of ten products is a fixture. Swapping it for a Supabase-backed loader doesn't change the cache shape; the `'use cache'` wrapper around `loadProduct` works identically with a database call.

## Why this build exists

Agent commerce is the surface where AI meets real money movement, and the design questions there have right and wrong answers, not vibes. The failure modes have to be named. The security shape has to be defended in code, not asserted. The cache discipline has to be visible at the route level, not hidden. Anchor is the artifact that demonstrates one coherent answer to those constraints. As a trilogy, forge covers agent orchestration, loom covers durable execution, and anchor covers discovery and transaction.

The build is open source. The eight checks live in [`lib/principal.ts`](https://github.com/midimurphdesigns/anchor/blob/main/lib/principal.ts) with stable failure codes for HTTP mapping. The agent descriptor is in [`lib/agents-descriptor.ts`](https://github.com/midimurphdesigns/anchor/blob/main/lib/agents-descriptor.ts), the single source for `/.well-known/agents.json` and `/llms.txt` and `/agents`. The proxy is in [`proxy.ts`](https://github.com/midimurphdesigns/anchor/blob/main/proxy.ts), about sixty lines. The test script that proves the five scenarios is in [`scripts/test-checkout.ts`](https://github.com/midimurphdesigns/anchor/blob/main/scripts/test-checkout.ts) with eleven assertions, all passing.

Fork it, run it, break it. The next category of commerce is the one where agents transact on behalf of users. The infrastructure to make that safe has to be built somewhere. This is what one shape of it looks like.

---
## Loom: durable AI commerce with Vercel Workflows, exactly-once side effects, and a bounded agent gate

URL: https://kevinmurphywebdev.com/blog/building-loom
Date: 2026-05-23
Tags: ai, durable-execution, agents, workflows, stripe, open-source, applied-ai
Excerpt: Loom is an open-source backend that demonstrates the patterns required to let an LLM influence real money movement safely. Four Vercel Workflows run end-to-end with durable sleep, exactly-once side effects, saga compensation, Stripe webhook drift reconciliation, and a deterministic authorization gate that bounds every agent decision. Ships with an adversarial eval harness and a failure-injection harness. Built on Vercel Workflows (GA), the Vercel AI SDK, Anthropic, Stripe, and Upstash.

> **Repo:** [github.com/midimurphdesigns/loom](https://github.com/midimurphdesigns/loom)
>
> **Live:** [loom.kevinmurphywebdev.com](https://loom.kevinmurphywebdev.com)

## What loom is

Loom is a durable-execution backend for AI-driven commerce. Four workflows run end-to-end on the live demo, each one demonstrating a class of failure that production AI-commerce systems have to handle correctly:

- **Cart abandonment** with a six-hour durable sleep and an idempotent email send. Proves the runtime survives process restarts and replay storms without duplicating customer-visible side effects.
- **Dynamic checkout** with an LLM-driven discount negotiation bounded by a deterministic authorization gate. Proves an agent can influence a real payment amount without ever being trusted with the ceiling.
- **Shipping monitor** with saga compensation. Proves a multi-step external integration can roll back cleanly when one step fails after another has already succeeded.
- **Stripe webhook drift** demo. Proves the system tolerates the four standard webhook failure modes (out-of-order, duplicate, late, never-arriving) by treating the event store as a durable log instead of a queue.

The whole thing is open source, MIT licensed, and hosted with per-IP rate limits and a daily USD spend cap so the demo is safe to leave on the internet.

## What's in the box

Every bullet maps to a layer in loom that the live demo exercises:

- **Durable execution** on Vercel Workflows (GA). Per-step checkpointing, durable sleep across replicas, automatic replay on failure.
- **Exactly-once visible side effects.** At-least-once delivery + receiver-side idempotency keys + stable composite keys (`workflowId:stepName`) → one email per real event under retry storms.
- **Saga compensation** with namespaced idempotency keys. The booking key and the cancel key live in different namespaces so the cancel call cannot dedupe-return the original booking record.
- **Stripe webhook drift reconciliation.** Events persist to a durable log with per-consumer cursors and TTL-based eviction; tolerates out-of-order, duplicate, late, and never-arriving webhooks.
- **Bounded agent authority** via structured-output contracts (`generateObject` + Zod discriminated union) and a deterministic authorization gate that runs after the LLM returns.
- **Adversarial eval harness** (Sirens) — 10 prompt-injection scenarios asserted against the gate in CI; snapshots to `.loom/sirens/<timestamp>.json` for diffing across model upgrades.
- **Failure-injection harness** — a `KillingProvider` throws between step execution and step recording (the worst case for durability); N trials asserts zero duplicate sends and zero drops.
- **Cost-aware model routing.** Haiku for generators, Opus for structured agent decisions. Per-day USD cap via Upstash with budget-aware short-circuit before every LLM call.
- **Cross-instance abort** — global signal in Redis, local actuator inside the workflow step. Anyone can fire; the workflow decides when it's safe to act.
- **Production-shaped guardrails.** Per-IP sliding-window rate limit, daily USD cap, per-visitor cookie scoping so demo events stay isolated between sessions.
- **TypeScript discipline.** Strict mode, zero `any`, discriminated unions everywhere a workflow outcome is rendered, typed cross-process state contracts.

## Stack

- **Vercel Workflows** (GA) for durable execution. Per-step checkpointing, durable sleep, automatic replay on failure.
- **Vercel AI SDK** on `@ai-sdk/anthropic` with Claude Opus 4.7 for structured agent decisions and Claude Haiku 4.5 for free-form generators.
- **Stripe** for the webhook receiver, signature verification, and idempotency-key semantics.
- **Upstash Redis** for the event log, per-visitor cursors, the budget counter, and the rate-limit window.
- **TypeScript** strict mode, zero `any`, Zod for structured-output schemas and runtime validation at every API boundary.
- **Next.js 16** App Router on Vercel.

## The problem

Letting an LLM influence customer-facing money movement requires solving two problems at once. The first is durability: AI workflows often involve external API calls, long waits, and multi-step coordination. A naive implementation drops state when a process dies and re-fires side effects on retry. The second is bounded agent authority: an LLM that can write a discount amount can be coerced into writing a wrong discount amount by an adversarial input, a hallucination, or a prompt-injection attack carried by a customer message.

A production AI-commerce system has to solve both. Loom is an end-to-end demonstration of how.

## How loom bounds an agent's spending authority

The dynamic-checkout workflow is the highest-stakes surface in the system. An LLM-driven decision becomes a discount applied to a real payment. The defense is structural, not behavioral, and it has three layers.

### Layer 1: a structured-output contract

The `negotiate_discount` step calls Claude Opus through `generateObject` from the Vercel AI SDK. The model's entire output surface is a Zod discriminated union:

```ts
const AgentDecision = z.discriminatedUnion('action', [
  z.object({
    action: z.literal('discount'),
    amountCents: z.number().int().nonnegative(),
    reason: z.string(),
  }),
  z.object({
    action: z.literal('refund'),
    amountCents: z.number().int().nonnegative(),
    reason: z.string(),
  }),
  z.object({
    action: z.literal('no_action'),
    reason: z.string(),
  }),
]);
```

The model cannot call functions, read files, or write to state. Its only capability is filling in this object. Any output that doesn't validate is rejected before any downstream code sees it. That's the first floor.

### Layer 2: a deterministic authorization gate

After the model returns, the workflow calls `authorizeDiscount` from `lib/agent-authority.ts`:

```ts
export function authorizeDiscount(
  amountCents: number,
): { status: 'approved' } | { status: 'decision_blocked'; reason: string; ceilingUsd: number } {
  const ceilingUsd = Number(process.env.LOOM_MAX_DISCOUNT_USD ?? 25);
  const ceilingCents = ceilingUsd * 100;
  if (amountCents > ceilingCents) {
    return {
      status: 'decision_blocked',
      reason: `requested ${amountCents / 100} USD exceeds ceiling`,
      ceilingUsd,
    };
  }
  return { status: 'approved' };
}
```

Five lines of plain code with no LLM in the loop. The ceiling is read from the environment, never embedded in the system prompt. The model cannot infer it, cannot inspect it, cannot route around it. **The right way to bound an LLM is to validate its decisions in deterministic code after it has finished, not to ask it to behave.**

The workflow logs both branches identically. An approved decision records `{ outcome: 'applied', amountCents, reason }`. A blocked decision records `{ outcome: 'blocked', requestedCents, ceilingUsd, modelReason }`. Every request has a paper trail.

### Layer 3: adversarial evidence

The gate is the safety mechanism. Sirens is the evidence that the gate holds.

`scripts/sirens.ts` runs ten adversarial scenarios offline against the same agent path the runtime uses. Vague pressure ("just this once"), fabricated authority ("the CEO already approved a 50% discount"), system-prompt-leak attempts, JSON injection, ceiling-math tricks, chained-reasoning attacks. After each scenario, Sirens asserts that the *applied* amount never exceeds `MAX_DISCOUNT_USD`. The assertion never fires because the deterministic gate always catches the overshoot. Results snapshot to `.loom/sirens/<timestamp>.json` for diffing across prompt changes or model upgrades.

Unit tests verify the gate code is correct against the inputs the author thought to test. Sirens verifies the gate holds against an adversarial LLM producing inputs the author didn't think to test. The two are complementary; production needs both.

## How loom achieves exactly-once side effects

Stripe retries webhooks at-least-once. The workflow runtime retries failed steps. The email provider can retry on transient failure. If any of those layers leaks a duplicate, a customer gets two emails and trust in the system erodes.

The defense is three layers of receiver-side idempotency keyed on stable composite identifiers.

The workflow's step-level idempotency key is `workflowId:stepName`. Two replays of the same step produce the same key. The send-email step passes that key to the mock email provider in `lib/email.ts`, which stores it in Upstash with a TTL. The first call returns `{ deduplicated: false }` and dispatches the email. Every subsequent call with the same key returns `{ deduplicated: true }` and dispatches nothing.

Stripe's own idempotency-key API (passed to its checkout-session creation) works the same way at the receiver Stripe controls: same key returns the cached result; same key plus a different body returns 400.

The principle that makes this composable: **at-least-once delivery plus idempotent receivers plus stable keys equals exactly-once visible side effects.** Senders are never trusted to send exactly once because they can't be — networks are unreliable. Receivers are trusted to ignore duplicates because the receiver is the only place that can be authoritative about whether the side effect has already happened.

## Idempotency keys versus sagas

Idempotency keys prevent duplicate side effects. They do not undo side effects that already happened and turned out to be wrong. That is what sagas are for. **Idempotency keys prevent; sagas compensate.**

The shipping-monitor workflow demonstrates the difference. It books carrier A, then attempts carrier B. If carrier B raises a `CarrierFailureError`, the workflow runs a paired compensation step that cancels carrier A's booking.

The implementation detail worth naming: the booking step and the compensation step use idempotency keys in *different namespaces*. The booking key lives at `loom:carrier:booking:<workflowId:book_carrier_a>`. The cancel key lives at `loom:carrier:cancel:<workflowId:cancel_carrier_a>`. If they shared a namespace, the cancel call would dedupe-return the original booking record and the cancel would silently no-op. Same workflow id, different namespaces. The namespace is the *type* of side effect, not the workflow it belongs to.

The saga's outcome is one of two typed values: `completed` or `rolled_back`. A discriminated union, not a boolean. The UI renders which path the saga took, including which carrier failed and what the compensation returned, so the audit log is self-evident.

## Stripe webhook drift reconciliation

Webhooks are not ordered, not exactly-once, and not predictably timed. A `payment_intent.succeeded` event can land before, during, or after the workflow that needs it. Code that assumes any one of those orderings breaks under load.

The defense is to treat the webhook store as a durable log with multiple readers, not as a queue. The receiver verifies the Stripe signature, persists the event to `loom:stripe:event:<id>` with a thirty-day TTL, and appends the id to a per-visitor list. The receiver does not trigger any workflow. Workflows are independent consumers; each one walks the log when it's ready, tracks its own cursor (a consumed-set in Redis), and picks the first unconsumed event newest-first.

The principle: **webhook stores are durable logs, not queues. Multiple consumers, individual cursors, TTL-based eviction.**

The demo's receive-then-consume-later flow makes the two timing modes visible:

The first failure mode is the webhook arriving before the workflow asks for it. If the store were a queue and had been consumed, the workflow would wait forever. Because it's a durable log with a cursor, the workflow's consumer reads from the beginning and finds it.

The second failure mode is the workflow running before the webhook arrives. The consumer polls the store, finds nothing, sleeps, retries. Durable sleep means the retry doesn't cost a process; the workflow is paused, not spinning.

Click "send test webhook" three times in the demo to stack events. Click "fire consumer workflow" once to advance the cursor by one event. Consumed events dim with a checkmark and stay in the store. The store does not shrink; the consumer's cursor advances. That visual is the entire argument for why webhook stores are not queues.

## Durability under fault injection

Durability claims are cheap. Durability evidence is what makes them credible.

`scripts/failure-injection.ts` runs the cart-abandonment and dynamic-checkout workflows against a custom `KillingProvider` that wraps the durable step recorder. The provider's job is to crash the workflow at the worst possible moment.

The worst possible moment is *after* `await fn()` returns but *before* the step's result is persisted. The side effect has happened (the email was sent, the discount was applied), but the workflow has no memory it happened. On replay, the workflow asks the recorder for that step's result, the recorder has nothing, and the workflow re-runs `fn()`. The side effect is about to fire a second time.

That is exactly when the receiver-side idempotency key earns its keep. The second call carries the same composite key as the first. The receiver returns `{ deduplicated: true }`. The visible side effect (an outbound email, a discount application) fires exactly once. The harness runs N=5 trials per workflow per phase and asserts recovery completed, the email audit log shows exactly one entry per workflow run, and no sends were dropped. Zero duplicates. Zero drops.

That's the difference between "the system is durable in theory" and "the system has measured durability under fault injection."

## Cost-aware model routing

Every LLM call in loom is bracketed by `assertWithinBudget` before and `recordSpend` after. The budget lives at the Upstash key `loom:cost:YYYY-MM-DD` and increments atomically via `incrbyfloat`. If the day's spend exceeds `LOOM_DAILY_USD_CAP` (default $2), the call short-circuits with `BudgetExceededError` before reaching Anthropic. The live cost panel polls the counter in real time.

The other half of cost discipline is which model gets each seat.

Haiku drafts the re-engagement email in cart-abandonment. Haiku is the right tool for classifiers, summarizers, and unconstrained text generators where the worst case is "the prose is fine but not great."

Opus runs `negotiate_discount`. Opus is the right tool for structured agent decisions and anywhere the model's reasoning needs to be defensible enough to log as audit evidence. Haiku at this seat would produce reasons like "discount because customer asked," which would make the audit log useless.

The principle: **match model cost to blast radius.** Cost-per-token differs by roughly 15x between Haiku and Opus depending on cache state. Defaulting every call to Opus is how small projects burn through credit before they ship. Defaulting every call to Haiku is how an agent gives away a free discount because the model could not produce a defensible refusal.

## Cross-instance abort

If a workflow runs across multiple replicas and a user clicks an abort button, the abort signal has to reach every replica that might be running the workflow. The actuator — the JavaScript object that can stop work — cannot leave the process it lives in. The signal — the intent to stop — has to be readable from any process.

The pattern: **the signal is global; the actuator is local.**

The signal is an Upstash key, `loom:workflow:<id>:abort`. Any client can set it. The actuator is a check inside the workflow step that consults the key before performing a side effect. If the key is set, the step throws `WorkflowAbortedError`, the runtime stops scheduling further steps, and the workflow records `outcome: 'aborted'`. If the actuator lived where the signal lived, the system would be coordinating cancellation across nodes — the classic distributed-systems trap. Decouple them: anyone can fire the signal; the workflow decides when it's safe to act on it.

## What loom intentionally does not solve

A production deployment of these patterns would close four gaps that the demo leaves open on purpose.

**The dispatcher.** A real system needs a transactional-outbox dispatcher between webhook receive and workflow start. If a process crashes between writing the event to the log and triggering the workflow, the event sits unread. The fix is the transactional-outbox pattern with a watchdog that re-queues unconsumed events older than a threshold. Loom's architecture doc names this Phase 7 as a deferred item; production must close it.

**Real adapters.** The carrier API, the email provider, and (for everything past checkout-session creation) the Stripe checkout flow are fixture-backed. Each one becomes a real adapter with retry-with-backoff, circuit breakers, and provider-specific idempotency semantics.

**Multi-tenant cost ceilings.** Loom's cost cap is global to the demo. Production needs per-team-id cost ceilings, per-team rate limits, and per-team idempotency namespacing.

**Observability.** Every workflow, every step, every LLM call, every adapter call needs an OpenTelemetry span so a stuck workflow is debuggable by an SRE who has never read the source. Loom logs to console; production traces through Honeycomb or equivalent.

The architecture is shaped for these additions; the demo intentionally stops before them.

## Three patterns to take away

**Idempotency is a system property, not a function annotation.** The composite key, the receiver-side dedup, the namespace separation between booking and cancel, the workflow-step idempotency — all of it has to compose. A single layer doing it correctly isn't enough; the chain has to be unbroken from caller to receiver. Drawing the chain explicitly and naming the dedup point at every hop is the move that catches the bug before deploy.

**The deterministic gate is the safety; the eval harness is the evidence.** The five-line `if` statement is what stops the $10k discount in production. Sirens proves the `if` holds against attacks an adversarial designer made. Both, drilled into CI, is what makes an agent-driven system trustworthy enough to ship in front of customers.

**Match model cost to blast radius.** Haiku is the default. Opus appears at exactly the seats where its reasoning premium pays for itself — structured decisions whose `reason` field becomes audit log evidence, negotiations whose output has to read as defensible refusal under scrutiny. Everywhere else, Haiku.

If you want to see all of this run end-to-end, [loom.kevinmurphywebdev.com](https://loom.kevinmurphywebdev.com) is the live demo. The full source is at [github.com/midimurphdesigns/loom](https://github.com/midimurphdesigns/loom). The most useful files to read in order are `docs/ARCHITECTURE.md`, `lib/agent-authority.ts`, `lib/workflows/*.ts`, and `scripts/sirens.ts`.

---
## Building Forge: a multi-agent debugging concierge in production-grade TypeScript

URL: https://kevinmurphywebdev.com/blog/building-forge
Date: 2026-05-22
Tags: ai, agents, multi-agent, streaming, open-source, applied-ai
Excerpt: Forge is an open-source multi-agent debugging concierge. Point it at a stack trace and four specialist subagents fan out in parallel, each with a focused tool set, before a coordinator merges their structured findings into ranked hypotheses with calibration-weighted confidence. Built on the Vercel AI SDK plus Anthropic. This post walks the architecture: parallel agent orchestration, durable resumable sessions, cross-instance abort, Brier-score calibration as a feedback loop, and what production deployment would change.

> **Repo:** [github.com/midimurphdesigns/forge](https://github.com/midimurphdesigns/forge)
>
> **Live:** [forge.kevinmurphywebdev.com](https://forge.kevinmurphywebdev.com)

## What forge demonstrates

Every bullet below maps to a layer in forge that the live demo exercises:

- **Multi-agent orchestration** with the Vercel AI SDK. Parallel fan-out via `Promise.all` plus `pLimit` bounded concurrency, four specialist subagents with focused tool sets, structured discriminated-union outcomes.
- **Two-pass agent pattern** for reliable structured output from a tool-using loop (`generateText` then `generateObject`).
- **Durable session state** across serverless replicas. UUID in URL, Upstash-backed store, work-vs-transport separation so refresh resumes from a snapshot without re-running the agents.
- **Preemptive abort** plumbed end-to-end via AbortSignal. UI click flips an Upstash flag, the coordinator polls cross-instance, an in-process AbortController cancels the in-flight Anthropic fetch.
- **Brier-score calibration** as a feedback loop. Every (predicted confidence, rubric outcome) pair gets logged, weights derive from mean outcome divided by mean predicted with a clamp, applied at merge time.
- **Anthropic prompt caching** with cache-control breakpoints on system messages, instrumented with per-call USD pricing and cache hit rate in the cost dashboard.
- **Graded eval rubric** with intersection-over-union scoring for line ranges, n-runs aggregation, and per-scenario mean plus standard deviation for statistical significance.
- **Speculative tool-input prefetch**. Predict the next tool call based on the current one and fire it in parallel with the LLM's thinking time.
- **Production-shaped guardrails**. Upstash sliding-window rate limit per IP, daily USD cap shared across all visitors, owner-bypass cookie.
- **TypeScript discipline**. Strict mode, zero `any`, discriminated unions driving exhaustive UI rendering, typed cross-process state contracts.

Forge is a multi-agent debugging concierge. Paste a stack trace. Four specialist subagents run in parallel, each with a focused tool set and a tight system prompt. A coordinator merges their structured findings into ranked hypotheses, weights confidence by per-lane Brier calibration, and streams progress events to the browser as the work unfolds. Every refresh resumes the in-flight session. Every stop button cancels the underlying LLM call mid-stream.

The interesting problems forge solves are not in any single LLM call. They are in the seams: how parallel agents reason without contaminating each other, how a serverless system survives the user refreshing the tab, how an abort button cancels a fetch running on a different replica, and how a multi-agent system gets more reliable over time without any individual agent improving.

The rest of this post walks the architecture, the trade-offs, and what production deployment would change.

## What is on screen

Open the live URL and you see one page. A short visitor header explains what is going on. Below that, a single button kicks off an investigation against a hardcoded sample input (a TypeError stack trace plus a deployed commit SHA plus an error timestamp plus a fingerprint, visible behind a disclosure toggle).

Click run. Four lane cards transition from queued to running to done, in parallel, in roughly three to eight seconds each. As lanes finish, the coordinator merges results into ranked hypotheses with confidence percentages. A calibration panel shows the per-lane Brier scores and the weights derived from them. A cost panel breaks down input tokens, output tokens, cache reads, and USD per lane. An eval harness section renders a representative snapshot from the CLI runner.

That is the surface. The interesting part is what runs underneath.

## The four specialists

Forge has one design rule that pays for itself across the whole codebase: specialize, do not generalize. A naive agent gets a stack trace, the full repo, the full error log, and a giant prompt that says "figure it out." Real-world agents fail at this because the model has to context-switch between four reasoning modes, the prompt gets bloated with everything it might need, and the output is one monolithic answer that is hard to grade.

Forge splits the work across four subagents, each with one job, one focused tool set, and a structured output contract.

**source-reader** identifies the implicated source files from the stack trace and reads the code at the relevant lines. Its tools are fetch_file and fetch_directory. Its output is a typed object with file path, line range, snippet, surrounding context, confidence, and reasoning.

**blame-correlator** finds recent commits that could have caused the error and ranks them by relevance. Its tools are git_log, git_diff, and git_blame. Its output is a list of candidate commits with relevance scores plus a single top suspect plus an aggregate confidence.

**frequency-analyzer** quantifies the blast radius. How often does this error fire, how many users hit it, is it spiking. Its tools are error-tracking queries. Its output is a structured severity report with a p0 through p3 classification.

**repro-drafter** writes a minimal local reproduction from the stack trace alone. Its tool set is empty by design. Its output is numbered steps, optional code, environment requirements, and an honest list of gaps in its assumptions.

The design contract for these lives in [docs/AGENTS.md](https://github.com/midimurphdesigns/forge/blob/main/docs/AGENTS.md) on the repo. I wrote that doc before writing any of the agent code. The point of writing it first was to be able to re-explain the system from words six months later by reading the doc, closing it, and reconstructing the architecture from the contract alone.

## Why parallel beats sequential

The naive way to build this is a sequential agent loop. source-reader runs, its output flows into blame-correlator, which flows into frequency-analyzer, which flows into repro-drafter. That structure has two real problems.

The first is latency. Four 3-second calls run sequentially equals 12 seconds. In parallel they are ~3 seconds. User-facing latency is the headline win.

The second is more subtle and more important. Sequential reasoning means each agent's hypothesis contaminates the next agent's framing. If source-reader concludes "this is a null deref in auth.ts," blame-correlator anchors on that and stops considering "maybe the stack trace is misleading because of a sourcemap mismatch." Parallel reasoning lets each lane form an independent hypothesis. The coordinator then merges with a calibration-aware weighting. This is the same independence-of-evidence trick that ensemble methods in classical ML use.

Forge implements this with a single fan-out in `lib/coordinator.ts`:

```ts
const limit = pLimit(4);

const outcomes = await Promise.all(
  LANES.map((lane) =>
    limit(async (): Promise<LaneOutcome> => {
      try {
        const value = await lane.run(input, sessionId, controller.signal);
        return { lane: lane.name, status: "fulfilled", value, durationMs };
      } catch (err) {
        return { lane: lane.name, status: "rejected", reason, durationMs };
      }
    }),
  ),
);
```

Two small things in that snippet are doing real work. First, `pLimit(4)` is a bounded-concurrency primitive. It caps how many promises run at once within a single request. For four lanes that is not the win it sounds like; the real win is the pattern. In a production version with twenty lanes you would set pLimit to four or five and the fan-out would run in waves.

Second, every lane catches its own errors and returns a typed `LaneOutcome` discriminated union. That is why the code uses `Promise.all` instead of `Promise.allSettled`. The outer promise never rejects because the inner functions never throw. The coordinator gets back a typed array of outcomes, each one either fulfilled with a result or rejected with a reason. No mixed-rejection batch semantics, no try-catch wrapping the merge logic. Each lane already returns success or failure as data, so the outer promise never sees a rejection.

## The two-pass agent pattern

Every LLM call has one job that defines its output shape. Either it loops with tools and produces unstructured text, or it produces a typed JSON object with no tools. Trying both in one call fails two ways. The model either skips tools and hallucinates the answer, or calls tools correctly and produces schema-invalid JSON. The Vercel AI SDK actually enforces this by refusing the tools argument on generateObject.

Forge's subagents use a two-pass pattern. Pass one is `generateText` with the tool set and a step-count stop condition, producing a free-form investigation transcript. Pass two is `generateObject` with no tools and a Zod schema, coercing the transcript into the typed result shape.

```ts
const investigation = await generateText({
  model: anthropic(MODEL),
  messages: [...],
  tools: { fetch_file, fetch_directory },
  stopWhen: stepCountIs(6),
  abortSignal: signal,
});

const { object } = await generateObject({
  model: anthropic(MODEL),
  schema: Schema,
  prompt: `Investigation transcript:\n\n${investigation.text}\n\nProduce the structured result.`,
  abortSignal: signal,
});
```

Two calls per lane, four lanes, equals eight LLM calls per investigation. The cost dashboard in the live demo shows roughly $0.08 per full run on Claude Sonnet 4.6. That is the price of reliable structured output from a tool-using loop. The alternative, fighting the model to produce both at once, fails too often to be useful in production.

## Resumable streams

Forge's investigation is a first-class durable session. Every state transition writes to a session store keyed by a UUID. The browser captures the UUID from the first SSE frame and pins it to the URL via `history.replaceState`. Refresh the tab and the page reads `?sessionId=X`, fires a GET to `/api/debug?sessionId=X`, and the server replays the buffered lane state plus the merged hypotheses.

The trick worth knowing here is what crosses the refresh boundary and what does not. Three things cross.

**The UUID in the URL.** Persisted in the browser's address bar across reloads.

**The session state on the server.** A map of lane statuses, results, durations, plus the merged hypotheses, plus the cost summary. Lives in process memory in dev. Should live in Upstash or KV in production.

**The work itself.** The original POST's `runCoordinator` keeps running regardless of whether the client is connected. Two browsers can resume the same UUID simultaneously and both see the same snapshot. They do not interfere with each other because the GET handler is read-only.

What does NOT cross is the live tail. The resume GET returns a snapshot of the state at the moment of the GET, not a subscription to future updates. If you want to watch the still-running lanes continue, you need to refresh again, or build an event bus the POST publishes to and the GET subscribes to. I deferred that layer. Snapshot-on-refresh is enough for the demo's use case and the architectural pattern is what matters.

The architectural shape to internalize is the separation of work and transport. The work (the agent loop) runs to completion on the server regardless of whether anyone is listening. The transport (the HTTP response stream) is just one possible subscriber to the work's progress. Resume reconnects the transport to a different snapshot of the same work, not to the work itself.

## Per-lane interrupt and the signal-vs-actuator distinction

The stop button on each lane card was the bug that taught me the most about serverless.

Version one was cooperative abort. The interrupt POST set an in-memory flag, and the coordinator checked the flag at lane-task boundaries (before start, after lane.run returns). The flag worked perfectly on localhost. The same fan-out runs in one Node process; the interrupt POST and the coordinator share heap memory; the flag flip is visible everywhere.

On Vercel it failed silently. The investigation kept running for 30 seconds after I clicked stop, then completed normally. Vercel's serverless runtime can route consecutive requests from the same browser to different replicas. The POST that started the investigation landed on instance A; the interrupt POST landed on instance B. Instance B's in-memory store was empty (different process, different heap), the session lookup returned null, the interrupt returned 404 before flipping anything, and instance A's coordinator never knew it was supposed to stop.

The fix is the lesson worth repeating. **The signal is global; the actuator is local.**

The interrupt POST writes the abort signal to Upstash. The coordinator (running on a different replica, possibly) polls Upstash every 500ms and, when it sees the flag set, calls `controller.abort()` on its own local AbortController. The signal crosses processes via Redis. The actuator (the AbortController, with its `signal` reference and `abort()` method) is a JavaScript object that lives in heap memory in exactly one process and can only cancel work running in that same process. You cannot serialize a controller, you cannot ship it to another instance. So you signal across processes and actuate within them.

Then I plumbed `AbortSignal` end-to-end. Every subagent function accepts an optional signal and passes it through to both the `generateText` and `generateObject` calls via the AI SDK's `abortSignal` parameter. When the controller aborts, the underlying fetch to Anthropic closes its connection, the SDK promise rejects with AbortError, the coordinator catches it, distinguishes abort-shaped errors from real errors, and marks the lane aborted.

Round-trip from click to "aborted" badge is roughly one second on Vercel and effectively instant on localhost. The UI flips to an optimistic "stopping" state on click so the button feels responsive before the server-side abort lands.

What this layer actually demonstrates is that real cancellation in a distributed agent system requires three separate things working together. First, a shared signal: any process can read or write the abort intent, which is why it lives in Upstash. Second, a local actuator: each running process owns an AbortController that can cancel work happening inside it but nowhere else. Third, signal-awareness all the way down: every layer of the call stack, including the HTTP request the AI SDK fires to Anthropic, has to accept and forward the signal so the cancellation reaches the actual work. Miss any one of these and the stop button is decorative.

## Brier calibration and self-correction

LLMs are systematically overconfident. Source-reader will tell you it is 95% sure the bug is in `src/auth/session.ts` even when it is wrong. If the merge logic naively averages those overconfident self-ratings, the system's overall confidence is also overconfident, and the output becomes untrustworthy even when individual lanes happen to be correct.

Forge's calibration layer measures and corrects.

Every (predicted confidence, rubric outcome) pair is logged to Upstash after each session. The rubric scores each lane's structured output against a per-component grading scheme. For source-reader it is 40 points for file match, 20 points scaled by line-range intersection-over-union with the ground truth, 15 points for having a snippet, 25 points for having reasoning. The threshold for "this lane counted as correct" is total >= 60% of max. That threshold maps to "useful answer, not perfect answer," which is what a real reviewer applies when deciding whether to act on a hypothesis.

Across many runs, two numbers per lane are computed.

**Brier score** is the mean squared error between predicted confidence and binary outcome. Lower is better. Zero is perfect calibration. 0.25 is the no-information baseline (the score you get from predicting 0.5 on everything). Above 0.25 is worse than random and means the lane is actively misleading.

**Weight** is the ratio of mean outcome to mean predicted, clamped to the range 0.5 to 1.5 with a three-sample floor before weighting activates. A chronically overconfident lane sees its weight drop toward 0.5. A chronically underconfident lane sees its weight rise toward 1.5. The clamp prevents small-sample overcommitment in either direction.

At merge time, each lane's contributed confidence is multiplied by its weight before ranking. A lane with weight 0.6 has 40% less influence on the merged hypothesis than a lane with weight 1.0. The system gets more reliable hypotheses over time even though no individual lane improves.

The principle worth taking away: **calibration is system-level learning, not model-level learning.** The lanes are stateless function calls. The calibration log is the system's accumulated memory of which lanes to trust more or less. The lanes never get smarter. The merge gets smarter. That separation is the architectural win.

The honest gap I documented in the UI: real production calibration needs a real correctness oracle. Forge's rubric is a stub that knows the right answer for the demo's sample input. For arbitrary scenarios you need either labeled eval data (the harness's golden set) or implicit signals like user thumbs-up or bug-reopened-within-24-hours. Neither is free. The blog post is the place to be honest about this; the demo page now says it in plain text.

## Speculative tool-input prefetch

There is one more orchestration layer worth describing. The speculator pre-fires likely next tool inputs in the background while the LLM is still reasoning about the current tool's output.

When source-reader calls `fetch_directory("src/auth")`, a rule fires that says "the next call is almost certainly `fetch_file("src/auth/session.ts")` against the same SHA." The speculator immediately calls the file fetcher and stores the resulting promise in a cache. By the time the LLM actually requests the file, the result is sitting in the cache and the consume call returns instantly. On a real GitHub API where each call takes 800ms, this saves real latency.

Tracked metrics on the live demo show predictions, hits, misses, and in-flight count. The hit rate is the headline KPI. Below ~50% the speculator is wasting more calls than it saves.

This pattern only makes sense when three conditions hold. The next call is highly predictable given the current call. The tool calls have non-trivial latency. Wasted speculations are cheap. Forge satisfies all three for the directory-to-file navigation in source-reader. The blame correlator's `git_log` could speculate `git_diff(top_returned_sha)`, but the prediction is shakier (the LLM might want a different commit), so I left it out.

The pattern is streaming-aware tool orchestration: speculative execution borrowed from CPU pipelines, applied to LLM tool calls. The math that determines whether it is worth using is hit-rate times min(latency, thinking-time) minus waste-rate times cost. For Forge's mocked-fixture tools the actual production value is questionable. For a research agent calling expensive search APIs the pattern is a real win.

## The eval harness

A demo without measurement is just a vibe.

Forge ships with a CLI eval runner ([scripts/eval.ts](https://github.com/midimurphdesigns/forge/blob/main/scripts/eval.ts)) that takes five reproducible bug scenarios from [lib/eval/scenarios.ts](https://github.com/midimurphdesigns/forge/blob/main/lib/eval/scenarios.ts) and runs each one N times against the coordinator. Each lane's structured output is scored against the rubric in [lib/eval/rubric.ts](https://github.com/midimurphdesigns/forge/blob/main/lib/eval/rubric.ts). The runner reports per-scenario mean and standard deviation in raw points, plus mean duration in milliseconds. Snapshots write to `.forge/evals/<timestamp>.json` for diffing against prior baselines.

The two ideas that matter here are graded rubric and n-runs aggregation.

**Graded rubric.** A binary "correct or not correct" verdict throws away too much information. Source-reader returning the right file with a slightly-wrong line range gets full file-match credit and proportional IoU credit, totaling 75 of 100 points. That is a useful signal. The Brier outcome is then derived from the rubric total by the 60% threshold rule, but the rubric total itself drives the human-facing scoreboard.

**N-runs aggregation.** LLM output is non-deterministic. A single "v2 got 17 out of 20 right" run is a sample from a distribution, not proof of improvement. The runner does N runs per scenario and reports mean plus standard deviation. v2 is only a real improvement over v1 if v2's mean exceeds v1's mean by more than roughly two standard deviations. Without this discipline every prompt change is a vibes-based judgment.

The live demo page renders a labeled illustrative snapshot rather than firing a fresh server-side eval on every visit. That tradeoff is the senior-engineer move: running for every visitor would cost roughly $1.50 per click and trip the daily cap immediately. Serving a stale snapshot without labeling would lie about freshness. Illustrative-and-labeled, with a pointer to the CLI runner and the snapshot JSON path, is the honest middle.

## Prompt caching and the honesty rule

Every subagent's system message carries an Anthropic ephemeral cache breakpoint. The intention is for the static system prompt to be cached at Anthropic's edge and reused at 10% the input price on subsequent calls.

The catch: Anthropic requires a minimum of 1024 tokens to be cacheable on Sonnet. Forge's system prompts are roughly 80 to 110 tokens each, well below threshold. The breakpoints are syntactically correct but get silently ignored by the API.

I considered three options. First, pad the prompts artificially to clear the minimum and get fake-looking cache hits. Lying to a metric. Rejected. Second, move the cache breakpoint to include tool definitions, hoping the combined region exceeds 1024 tokens. Padding for its own sake. Rejected. Third, leave the prompts at their honest 80-to-110 token size and disclose in the UI that caching is below threshold for this demo's prompt sizes. Picked this.

The cost panel on the live demo now carries an orange "heads up" callout explaining the threshold and what a production-sized prompt with 3K to 5K tokens of instructions plus tool schemas plus RAG context would actually see. Expected reduction in per-call input cost is roughly 70 to 90% of the cached prefix portion once it activates. The cache layer is correctly wired; it just does not light up at this scale.

The general principle is that admitting a real limitation is more useful than faking a metric to make it look good. The cache layer is wired correctly; it doesn't activate at this scale. Disclosing that on the dashboard is what makes the rest of the cost data trustworthy.

## What I would change for production

Forge is a proof-of-concept architecture demo. A real production version for a customer running thousands of debug investigations per day would change five things.

**The session store moves to Upstash or KV.** The in-memory `Map<sessionId, SessionState>` works on localhost but degrades gracefully to broken on Vercel because each serverless invocation potentially has its own fresh heap. Resume across replicas requires shared storage. The `SessionStore` interface in `lib/store.ts` is shaped for this swap; the implementation switch is one file.

**A process-shared semaphore around every Anthropic call.** Current `pLimit(4)` is per-request bounding. At one hundred concurrent investigations that is four hundred parallel Anthropic calls, which will burn the org's tokens-per-minute quota in seconds. The fix is a distributed rate limiter (Upstash sliding window) keyed on the API key. Per-request pLimit caps fan-out within one investigation; global pLimit caps fan-out across all concurrent investigations. Two different bounding levels, both needed.

**Real GitHub and Sentry adapters with retry-with-backoff and circuit breakers.** The fixture tools today return canned data. Production tools need their own rate-limit handling, exponential backoff on transient errors, and a circuit breaker that fails fast when the upstream is down so the agent does not waste tokens flailing.

**Write-capable tools require idempotency keys, transactional outboxes, and saga compensation.** Forge's read-only fixtures make abort safe. A production agent that creates pull requests, posts to Slack, or charges a customer mid-run would leave side effects half-done on abort. Idempotency keys make retries safe at the receiver. Outboxes make multi-system writes atomic by staging the external call in a DB row inside the same transaction as the local state change. Sagas pair each step with a compensating step that rolls back partial work. That is also where Vercel Workflows or a comparable durable execution layer earns its keep: each step is checkpointed, mid-step crash means the next worker resumes from the checkpoint, no work duplicated.

**Per-tenant isolation and observability.** Production agents are multi-tenant. Calibration logs need to be scoped per team so team A's overconfident lane does not drag team B's weights down. Every LLM call, tool call, and lane needs an OpenTelemetry span so an SRE can debug a stuck investigation without reading source. Real observability is not a feature, it is the precondition for running this in front of paying customers.

## What I learned

Three lessons stick.

The signal is global; the actuator is local. Distributed systems pattern, but it cuts harder in AI infrastructure because the in-flight LLM call is the work that needs cancelling and the cancellation control is a per-process JavaScript object. Pin this phrase in your head; it will save you an hour of confused debugging the first time you ship an agent demo to Vercel.

The lanes are stateless; the system has memory. Calibration as a feedback loop in the merge layer is what separates a multi-agent system from a bag of LLM calls. The lanes never learn. The system learns by tracking which lanes to trust.

Every metric needs an honest defense. Cache hit rate of zero, illustrative eval snapshot, stub correctness oracle, in-memory session store on Vercel. Every shortcut Forge takes is documented in the UI or the README with what it would look like in production. The discipline of disclosing the gap, instead of hiding it, is the move that signals senior judgment.

If you want to see all of this in action, [forge.kevinmurphywebdev.com](https://forge.kevinmurphywebdev.com) is the live demo. The full source is at [github.com/midimurphdesigns/forge](https://github.com/midimurphdesigns/forge). The most interesting files to read in order are `docs/AGENTS.md`, `lib/coordinator.ts`, and `lib/store.ts`. Everything else is implementation detail.

---
## Auditing my own portfolio against Next.js 16: what I shipped, what I skipped, and why

URL: https://kevinmurphywebdev.com/blog/nextjs-16-portfolio-teardown
Date: 2026-05-20
Tags: nextjs, react, app-router, performance, vercel
Excerpt: I audited my own portfolio against the Next.js 16 upgrade guide line by line. Here's what the site uses, what it deliberately leaves out, and the v16 changes I caught only because I read the release notes instead of a tutorial.

I built this site from a clean App Router start on Next.js 16. Then I sat down and audited it the way a senior reviewer who knew the framework end-to-end would. The interesting question was never "did I use every primitive?" It was "did I make the right call for each one, and can I defend the omissions out loud?"

This post is the audit. The structure follows the spine that ended up mattering: what I used, what I deliberately did not use, what v16 caught me on after I read the upgrade guide, and the senior-engineer signals I am still trying to earn.

The aim is to write the post I wish I had read when I was first reasoning through Next.js App Router primitives from scratch, on a real site, against the v16 upgrade guide.

## The short version: best practices this site actually follows

If you want the headline before the deep dive, here are the calls that matter most:

- **Server components by default.** About 60% of the component files are server-rendered. `'use client'` lives at the leaves (animation hooks, event handlers, browser APIs), never at the root of a page tree.
- **SSG-first with explicit exceptions.** Every page that can be prerendered at build time is. ISR is opted into on one route where the content has a real expiry. Dynamic rendering is reserved for genuinely per-request work (RAG endpoint, OG image generator, RSS feed).
- **`dynamicParams = false` on every finite-slug route.** Blog and portfolio slug routes return 404 for anything not in the build-time list. Eliminates a slug-fuzzing surface.
- **`generateStaticParams` enumerates the URL space at build.** Combined with the line above, Next knows the full set of pages before any user request lands.
- **Metadata API is the spine, not a checkbox.** Per-post `generateMetadata`, a single `buildMetadata` helper, dynamic OG images via `next/og`, RSS feed handler, sitemap, robots, and JSON-LD on every page that has one.
- **Type safety from the route up.** `npx next typegen` generates `PageProps<'/blog/[slug]'>` so route params are typed from the actual filesystem instead of hand-written. Typecheck is wired to run typegen first so it self-heals on clean checkouts.
- **Strict TypeScript with `noUncheckedIndexedAccess`.** Catches the array-index `undefined` bugs that bite half a year after they ship.
- **Tailwind v4 CSS-first.** No `tailwind.config.js`. Design tokens live as CSS custom properties under `@theme` in `globals.css` where they belong.
- **Turbopack default.** v16 stable for both dev and build. No flag needed; the explicit `--turbopack` script flag has been removed.
- **Leaf-isolated dynamic imports.** Heavy animation modules load behind `next/dynamic` with `{ ssr: false }` so they ship in their own chunks after hydration instead of bloating the initial route bundle.
- **Bundle analyzer wired behind a flag.** `pnpm analyze` runs a production build with `@next/bundle-analyzer` and emits an interactive treemap. The v16 build output dropped its "First Load JS" column for RSC-correctness reasons, so the analyzer is the way to keep client bundle weight honest.
- **Defensive HTTP headers in `next.config.ts`.** `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy` denying camera and microphone and geolocation, plus immutable cache headers on `/fonts/*`.

The rest of the post walks each of these out, plus the primitives I deliberately did not use.

## Stack snapshot

Next.js 16.0 on the App Router. React 19 stable. TypeScript strict mode with `noUncheckedIndexedAccess` and `noImplicitOverride`. Tailwind v4 with CSS-first config in `globals.css`. Three font families via `next/font/google` plus a manually preloaded Migra italic for the display tier. Turbopack as the default bundler for both dev and build (one of the v16 things I had to remove a redundant flag for). Vercel for hosting. MDX for content via `@next/mdx`. No Pages Router code anywhere; the site started on App Router from day one.

That last detail matters. Most Next.js posts about App Router are migration stories from the Pages Router, and I have nothing interesting to say about that path because I never had to walk it. What I can speak to is reasoning about App Router primitives from a clean start.

## Server components are the default, and I treated them that way

Of about 85 component and route files in the repo, 35 have `'use client'` at the top. The other 50 are server components by default. Every client component earns its directive on one of five grounds: it uses hooks (`useState`, `useEffect`, `usePathname`), it attaches event listeners, it touches a browser API (canvas, WebGL, scroll position), it provides React context (Lenis), or it is an error boundary.

The pattern I worked hard to keep is "client components are leaves, not branches." The `Hero` component at `src/components/Hero.tsx` is a client component because it runs an opacity animation on mount. But it does not wrap the whole homepage. The page itself, at `src/app/page.tsx`, stays a server component. It reads from the file system (blog posts, projects), renders the static layout, and embeds the `Hero` as one leaf among several.

The same logic governs heavier pieces. The hero canvas effect (`HeroLogoRain`), the smooth-scroll provider (`LenisProvider`), and the magnetic cursor (`Cursor`) are all imported via `next/dynamic` with `{ ssr: false }`. They are browser-only by definition (canvas, scroll APIs, mouse tracking), and they are not on the LCP critical path, so deferring them keeps the initial HTML lean.

The diagnostic test for this is simple. Pick any page, look at the route file, and ask: how much of this tree could in principle render with `JSON.stringify` of plain data instead of a React-DOM hydration walk? On this site the answer for every route is "most of it." That is the win.

## File architecture and routing: what the `app/` tree actually looks like

The full top-level layout under `src/app/`:

```
src/app/
├── layout.tsx          single root layout, server component
├── page.tsx            home, server component
├── globals.css         tailwind v4 import + design tokens
├── error.tsx           route-level error boundary, client
├── global-error.tsx    root error boundary (wraps html + body)
├── not-found.tsx       custom 404
├── icon.tsx            runtime-generated favicon
├── apple-icon.tsx      runtime-generated apple touch icon
├── sitemap.ts          dynamic sitemap
├── robots.ts           robots.txt generator
├── about/page.tsx
├── contact/page.tsx
├── demos/page.tsx
├── resume/page.tsx     ISR, revalidate: 3600
├── blog/
│   ├── page.tsx        index, reads searchParams (tag, page)
│   └── [slug]/page.tsx dynamic segment, dynamicParams: false
├── portfolio/
│   ├── page.tsx        index, reads searchParams (tab)
│   └── [slug]/page.tsx dynamic segment, dynamicParams: false
├── api/kev-o/route.ts  RAG endpoint, Node runtime, streaming
├── feed.xml/route.ts   RSS handler with Cache-Control
└── og/route.tsx        dynamic OG image via ImageResponse
```

The shape under it: every URL segment is a folder, every page in that segment is a `page.tsx`. The folder structure is the URL structure. There is one root `layout.tsx` and no nested layouts. Each dynamic segment uses square-bracket folders (`[slug]`). API endpoints are `route.ts` files under `app/api/` colocated with their domain.

The advantages of this flat shape, on this site specifically:

The site has one visual chrome (header, footer, grain layer, command palette, cursor) that appears on every page. There is no logged-in vs logged-out shell, no marketing vs admin split, no settings sub-app. Adding nested layouts would require duplicating the chrome or adding pass-through layouts that exist only to re-export `children`. Either path is ceremony. One root layout is the right answer.

Colocating API handlers under `app/api/` instead of a parallel `pages/api/` tree (the old way) lets the route handler import server-only utilities (`lib/seo.ts`, `lib/content.ts`, the MDX corpus builder) without indirection. The Kev-O route handler reads the corpus JSON that the prebuild step writes under `public/kev-o-corpus.json`. The file path lookup is dead simple because there is one source tree.

The dynamic segments (`[slug]`) carry both `generateStaticParams` and `dynamicParams = false`. That combo lets Next prerender all 11 blog posts and 18 portfolio entries at build time, and 404 anything else. The list of valid URLs is decided by what is on disk, not by request-time logic.

## App Router primitives the site uses

A complete enumeration:

- **Single root `layout.tsx`.** Wraps every page. Sets `<html lang="en">`, preloads Migra italic with `<link rel="preload" fetchPriority="high">`, injects the RSS `<link rel="alternate">`, drops two JSON-LD blocks (Person, Website) into `<head>`, and renders the chrome (skip-to-content link, grain, header, footer, command palette) around `{children}`.
- **`page.tsx`.** Every visible URL has one. They are server components by default. The two dynamic segments (`/blog/[slug]/page.tsx` and `/portfolio/[slug]/page.tsx`) use the `PageProps<'/...'>` typegen helper for their params.
- **Dynamic segments (`[slug]`).** Both content surfaces (blog, portfolio) use a single dynamic segment with `generateStaticParams` + `dynamicParams = false`. The pages are prerendered; the URL space is finite and locked.
- **`searchParams` for filter state.** `/blog?tag=ai&page=2` and `/portfolio?tab=demos` read `searchParams` (now a Promise in v16, awaited per the upgrade guide) for filter and pagination state. This is the right call over a dynamic segment because the filter is a view of one underlying list, not a separate page. Search params change without rebuilding the route.
- **`error.tsx`.** A client component (Next requires this) that renders a recovery UI when a server component throws below it. Shows a "something broke" message with a `reset()` button that re-runs the segment. Scoped to everything under `app/` except the root layout.
- **`global-error.tsx`.** A client component that catches errors in the root layout itself, including hydration failures. Renders its own `<html>` and `<body>` because the root layout has crashed. This is the last line of defense.
- **`not-found.tsx`.** A custom 404 page rendered when `notFound()` is called or when a route segment does not exist. The blog and portfolio slug routes call `notFound()` when a slug is missing, which now (with `dynamicParams = false`) happens at build-time enumeration rather than at request time.
- **`route.ts` and `route.tsx`.** Six route handlers total: the Kev-O RAG endpoint (streaming, rate-limited), an admin endpoint, a health probe, an admin reset, the RSS feed handler, and the dynamic OG image generator. All declare `export const runtime = 'nodejs'` explicitly so there is no ambiguity about where they run.
- **`sitemap.ts` and `robots.ts`.** Special filenames Next recognizes. The sitemap reads the same `getPosts()` and `projects` data the rest of the site uses, so the URL list cannot drift from the actual content.
- **`icon.tsx` and `apple-icon.tsx`.** Runtime-generated favicons via `ImageResponse`. Lets the brand mark stay consistent without checking PNG assets into the repo.
- **`generateMetadata` on every page or layout that needs per-route metadata.** Per-post overrides for `ogTitle`, `ogSubtitle`, and `ogEyebrow`.
- **`generateStaticParams` on both dynamic segments.** Enumerates the URL space at build.

## App Router primitives the site does not use, and why

- **Nested `layout.tsx` files.** Not used. The site has one shell. A nested layout would either duplicate the chrome or pass-through `children`. Adding one as decoration is the kind of move that signals "I memorized the API" rather than "I made a decision."
- **`template.tsx`.** Not used. Templates differ from layouts in that they re-mount on every navigation instead of persisting. They are useful when a page needs to reset state between routes (a multi-step form, an animation that should restart). Nothing on this site needs that.
- **`loading.tsx`.** Not used. Same reasoning as the Suspense section below: no route on this site has real wait time on the server, so a loading state would be theatre that makes pages feel slower.
- **`default.tsx`.** Required only for parallel routes. The site has no parallel routes. Not needed.
- **Catch-all routes (`[...slug]`).** Not used. The blog and portfolio URL spaces are flat (`/blog/foo`, not `/blog/2026/04/foo`). A catch-all would let the URL space grow without code changes, which is the opposite of what `dynamicParams = false` is doing. The two design choices point in opposite directions, and locking the URL space is the right answer for a portfolio.
- **Optional catch-all routes (`[[...slug]]`).** Not used. Same reasoning as catch-all, plus the optional variant is for cases where the segment may be empty. No use case here.
- **Private folders (`_name`).** Not used. Private folders are a way to put utilities inside `app/` that should not be picked up as routes. The site uses `src/lib/` and `src/components/` for that, which is the more common pattern and lives outside `app/` entirely. The private-folder convention is mostly useful for monorepos or for projects that want everything under `app/`.
- **Route groups (`(name)`).** Not used. Route groups let you share a layout across a subset of routes without affecting URLs. With one root layout and no shell variations, there is nothing to group.
- **Parallel routes (`@slot`).** Not used. Closest call was the `/portfolio` tabs (work, demos, OSS). I went with `searchParams` instead because the tabs are filter views on one list, not three URL-addressable panels.
- **Intercepting routes (`(.)foo`, `(..)foo`).** Not used. The Instagram-style modal pattern is the canonical use case (click a thumbnail to open a modal that shows the same URL as the standalone page). No need here.

The pattern across the "not used" list is the same: every primitive solves a specific problem. If the project does not have that problem, adopting the primitive adds complexity without adding capability. Saying so out loud is the senior signal.

## Error boundaries: three layers, deliberately

The error handling story spans three files, in order from innermost to outermost:

`notFound()` calls inside `page.tsx` handlers trigger the nearest `not-found.tsx`. That covers "this content does not exist" (a slug that was not in `generateStaticParams`, a search that returned zero results that should 404 instead of empty).

`error.tsx` catches runtime errors thrown inside server components below the root layout. It is a client component because the recovery UI needs interactivity (the `reset()` button). It renders inside the root layout, so the header and footer still show; only the page content gets replaced.

`global-error.tsx` catches errors in the root layout itself, including hydration failures. It renders its own `<html>` and `<body>` because the chrome it would normally rely on is what crashed. This is the bottom of the stack.

The three layers are not interchangeable. Putting your error UI in only `error.tsx` means a broken root layout shows a blank page. Putting it in only `global-error.tsx` means every recoverable error in a page replaces the entire shell. Both files exist on this site for that reason.

## Rendering modes: SSG-first, one ISR exception, dynamic only where it has to be

The route table from `next build` for this site reads:

```
○  /                           static
○  /about                      static
○  /contact                    static
○  /demos                      static
●  /blog/[slug]                SSG (11 paths)
●  /portfolio/[slug]           SSG (18 paths)
○  /resume                     ISR (revalidate: 1h)
ƒ  /api/kev-o                  dynamic (Node runtime)
ƒ  /og                         dynamic (ImageResponse)
ƒ  /feed.xml                   dynamic
```

The static pages are static because their content is checked into the repo. The dynamic segment routes (`/blog/[slug]`, `/portfolio/[slug]`) use `generateStaticParams` to enumerate the slugs at build time, and I added `export const dynamicParams = false` while writing this post. That last setting is the senior signal. It tells Next that any slug not in the build-time list returns 404 instead of being lazily rendered at request time. It eliminates a class of fuzzing surfaces (poking at random `/blog/foo-bar`) and it makes the route deterministic. A careful reviewer would ask why `dynamicParams = false` is not on every route that has a finite slug set. It is a one-line change with no downside.

The one ISR route is `/resume`. The resume PDF is generated by Puppeteer in a postbuild step, but the resume page itself displays metadata about certifications. Some of that metadata has expiry dates. Once an hour the page revalidates so the rendered "expires in N months" copy stays accurate without needing a redeploy. The route declares `export const revalidate = 3600`.

The dynamic route handlers all serve traffic that cannot be precomputed. `/api/kev-o` is the RAG endpoint that streams Claude responses against an MDX corpus. `/og` is the dynamic Open Graph image generator (Satori under the hood via `next/og`). `/feed.xml` is the RSS feed, which I made dynamic on purpose so it can read whatever the latest published post is without a rebuild.

What I want to flag here is what is _not_ on the list. There is no route using PPR (Partial Prerendering) or Cache Components. I considered enabling `cacheComponents: true`. I decided against it. The mental model for Cache Components is built around the case where one route mixes a fast static shell with slow dynamic holes (a product page where layout is cached but price is per-user, a dashboard with a cached layout and a live data widget). This site does not have that pattern. Every page is either fully static or fully dynamic. Adding the Cache Components opt-in would introduce ceremony with no measurable benefit, and v16 explicitly warns that PPR works differently from the v15 canary, so the cost of opting in is real. The right call was to leave it off and write that decision down. If I ever add a route with mixed static and per-user data, that is when Cache Components earns its place.

## Metadata API: this is where the site invests

If I had to point to one area where this site invests deeply, it is the metadata surface. The site ships:

- `generateMetadata` on every dynamic route, with per-post overrides for `ogTitle`, `ogSubtitle`, and `ogEyebrow` (so a long post title renders as a short OG title on LinkedIn)
- A `sitemap.ts` route that emits every static and dynamic URL with `lastModified` derived from frontmatter
- A `robots.ts` that allows all, disallows `/api/`, and links the sitemap
- A `feed.xml` route handler that emits RSS 2.0 with `Cache-Control: public, max-age=600, s-maxage=3600`
- A dynamic OG image route at `/og` using `ImageResponse` from `next/og`, which renders Migra italic on a noise-textured canvas with custom fonts loaded from disk
- JSON-LD structured data in the root layout (Person, Website) and per page (Breadcrumb, Article, CreativeWork)
- `icon.tsx` and `apple-icon.tsx` that generate favicons at runtime so the brand mark stays consistent without checking PNG assets into the repo

The mistake I see in a lot of portfolio repos is treating metadata as a checkbox. Either there is no `generateMetadata` and the home page title leaks across every route, or there is one static `metadata` export per page with the same boilerplate copied around. The senior pattern is: define `buildMetadata` as a single helper (in `src/lib/seo.ts` here), and have every page call it with just the fields that actually vary. The helper handles the absolute URL, the OG image URL with query parameters, the canonical, and the JSON-LD shape. The page declares intent, not boilerplate.

One subtle v16 thing on metadata. `generateMetadata`'s `params` and `searchParams` are Promises now. The compatibility shim for sync access from v15 is fully removed. If you upgrade from 14 or 15 and you have not run `npx next typegen`, you will get type errors on every dynamic route. Running typegen produces a `PageProps<'/blog/[slug]'>` helper that types the route's params correctly without hand-written types. I converted both dynamic routes to use `PageProps<'/blog/[slug]'>` and `PageProps<'/portfolio/[slug]'>` while writing this post. The types are now sourced from the actual route structure instead of duplicated.

## Streaming, Suspense, and `loading.tsx`: deliberately absent

There are no `<Suspense>` boundaries in the codebase. There are no `loading.tsx` files. This was a deliberate call and it is one of the easier ones to defend.

`<Suspense>` and `loading.tsx` are streaming primitives. They earn their place when a route has real wait time on the server (a DB query, a slow third-party API, an LLM call) and you want to flush the parts of the page that are ready while the slow parts resolve in the background. The streaming model is genuinely beautiful, and PPR builds on top of it.

This site does not have those latencies. Every page either reads from the file system (sub-millisecond on a Vercel edge node) or it serves prerendered HTML. There is no point flushing an empty shell with a skeleton when the full page is already on the CDN. Adding a `loading.tsx` would actually make the page feel _slower_ because users would see a flash of the loading state.

The honest version of this answer: I would add a `<Suspense>` boundary the first time I introduced a server component that called a slow API. Right now the slowest server-side work on the site is a `Promise.all([getPosts(), getAllTags()])` that completes in single-digit milliseconds. The boundary would be theatre.

## Server actions, middleware, and proxy: also deliberately absent

There are no server actions in the repo (`'use server'` does not appear). There is no `middleware.ts`, and consequently no `proxy.ts` either (that is the v16 rename, which I will get to).

Server actions are the right tool for mutations triggered from your own UI. This site has no mutations. The contact page is a `mailto:` link. The Kev-O chat interface posts to a route handler at `/api/kev-o` because it needs streaming and external rate-limit checks that route handlers handle more cleanly. If I ever add a form (a guestbook, a newsletter signup), I will use a server action. Until then, adding one as decoration would be the same kind of mistake as adding a `loading.tsx`.

Middleware was harder to think through. The v16 release renames `middleware.ts` to `proxy.ts` and forces it to run on the Node.js runtime instead of the Edge runtime. That is a significant change. The reasoning in the release notes is that middleware was being used for too many things, and the "proxy" framing makes the file's actual job clearer: it sits in front of every matching request and decides whether to redirect, rewrite, or pass through. If you need Edge runtime auth checks, the upgrade guide says to keep `middleware.ts` for now (deprecated, but functional) and wait for the next minor release.

This site has no auth, no A/B testing, no locale negotiation (the global bilingual rule is explicitly overridden for this project, per ADR-018), and no per-request decisions to make. Adding a proxy would be code that runs on every page load and does nothing. So there is no proxy, and that is a deliberate choice rather than an oversight.

## next/image, next/font, next/script: the standard kit, used correctly

There is no raw `<img>` tag in the codebase. Two pages use `next/image`: the about page (headshot) and the hero on the home page. Both pass explicit `sizes` attributes for the responsive `srcset`. The hero image is the LCP element on the home page and carries `priority`, which is still the correct prop in Next 16 (a reference doc I cross-checked claimed it had been renamed to `preload`, but the actual v16 upgrade guide makes no such mention; do not trust secondary sources without verifying).

Fonts come through `next/font/google` for Space Grotesk, Geist Mono, and Instrument Serif. All three use `display: 'swap'` so the page renders immediately with the fallback font and reflows when the web font loads. The variables (`--font-space-grotesk`, `--font-geist-mono`, `--font-instrument-serif`) are exposed as CSS custom properties and consumed in `globals.css`.

The display font (Migra italic) is not on Google Fonts. It is a trial file licensed for evaluation, served from `public/fonts/migra/`. The font is preloaded explicitly in the root layout with `<link rel="preload" href="/fonts/migra/..." as="font" type="font/woff2" crossOrigin="anonymous" fetchPriority="high">`. This is one of the few places where I dropped to a raw `<link>` tag because `next/font/local` does not handle proprietary trial files cleanly. The trade-off is conscious.

The v16 image config changes worth noting (verified against the upgrade guide): `images.minimumCacheTTL` default went from 60 seconds to 4 hours. `images.qualities` went from "any value 1-100 allowed" to "only [75] allowed by default" with non-75 values coerced to the nearest allowed quality. `images.domains` is deprecated in favor of `remotePatterns`. I checked my code: no `quality` props anywhere (so the coerce-to-75 change is a non-issue), `images.domains` is not used (already on `remotePatterns`), and I am happy with the longer cache TTL.

## Bundle optimization: leaf isolation plus dynamic imports

The v16 release removed the `size` and `First Load JS` columns from the `next build` output because the team felt the numbers were inaccurate in RSC architectures, and they recommend Chrome Lighthouse or Vercel Analytics instead. That is fine advice for production monitoring but it does not replace a static analyzer for "did I just balloon the home page bundle by adding a new dependency."

So `@next/bundle-analyzer` is wired into `next.config.ts` behind an `ANALYZE=true` flag, and `pnpm analyze` runs a full production build that emits an interactive treemap to `.next/analyze/`. The flag means the analyzer never runs on regular builds (no overhead), but it is one command away whenever a PR feels like it might have ballooned a route.

The static analyzer is half of the story. The other half is the leaf-isolation pattern enforced at code-review time. Any new `'use client'` directive has to justify why the whole component (not just the interactive part) needs to be on the client. The heavy animation modules (`HeroLogoRain`, `LostSignal`, `ChromaticField`) are all behind `next/dynamic` with `{ ssr: false }`, which means they ship in their own chunks loaded only after hydration. The MDX rendering happens entirely on the server. The MDX runtime is not in the client bundle.

## What v16 caught me on, after I read the upgrade guide

These are the actual changes I made to this codebase while writing this post:

1. Added `export const dynamicParams = false` to both dynamic routes. This is a senior signal that has nothing to do with v16 specifically, but writing the post is what made me realize I had not done it.
2. Converted `params` types to use the new `PageProps<'/...'>` helper from `npx next typegen`. The helper has been around since 15.5 but the upgrade guide is what pointed me at it.
3. Removed the `--turbopack` flag from the `dev` script in `package.json`. Turbopack is the default for both dev and build in v16; the flag is now redundant. The build still passes (and is noticeably faster than the v15 webpack default).
4. Audited image config defaults against the v16 changes. Nothing needed to change; the project already uses `remotePatterns`, has no `quality` props, and accepts the new 4-hour `minimumCacheTTL`.
5. Confirmed the `proxy.ts` rename and the Edge-to-Node runtime change does not affect me because there is no middleware. Filed the knowledge for next time.
6. Verified the v16 parallel-routes change does not bite this site. Parallel route slots now require an explicit `default.js` file or the build fails. The site has no parallel routes, so this is a non-issue, but the `npx @next/codemod@canary upgrade latest` codemod handles it automatically if you have any.
7. Replaced the runtime MDX dynamic import (an `await import()` call whose path was a template string built from the slug) with a build-time-generated static registry. Hot reload silently no-op'd on content edits because the template-string `import()` was opaque to Turbopack's dependency tracker; static imports give the bundler something it can trace.

The pattern under all of these is the same: read the upgrade guide front to back. Do not trust tutorials, do not trust secondary sources, do not trust your own assumption that "I am on the latest version." The release notes are the contract.

## What I would build next if this site had to grow

If I had a hypothetical Year Two of this site, here is the order I would reach for the unused primitives:

If I added a contact form with a real submit, that gets a server action with `updateTag()` after the database write so the user sees their submission immediately instead of waiting on stale data.

If I added a /now page that pulled from a slow source (latest GitHub activity, last book finished), that gets `'use cache'` with a `cacheTag` and a `cacheLife({ expire: 300 })` to make the page mostly static while the slow data refreshes on its own SWR window. Enabling `cacheComponents: true` unlocks this.

If I added a logged-in surface, that gets a proxy.ts with a Node-runtime auth check, plus `'use cache: private'` on the per-user data.

If I added a real admin route, that gets a `loading.tsx` (because admin routes have legitimate latency from real database queries) and probably parallel routes for the dashboard panels.

None of these are speculative refactors I should ship now. They are upgrades that earn their place the moment the requirement that justifies them lands.

## The signal I am trying to send

The frame I want anyone reviewing this site to leave with is: this person has read the docs, made deliberate decisions, and can explain the omissions out loud. Not "this person used every primitive in the framework." That second frame is the failure mode for portfolio sites. Every primitive earns its place; the ones that do not earn it stay on the shelf.

The move I would recommend if you are doing the same exercise on your own Next.js site: audit it against the v16 upgrade guide line by line. For every primitive you do not use, write down one sentence on why. If you cannot defend an omission in one sentence, that is the gap. Fix it, or write the sentence and move on.

## Glossary

If you are newer to the Next.js ecosystem, a few terms used above with definitions that lean on how Vercel uses them in their own docs, with my own framing where I think it helps.

**App Router.** The current Next.js routing model, built around the `app/` directory. Each folder is a URL segment and special filenames (`page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`, `route.ts`) declare what each segment does. The predecessor is the Pages Router, which used `pages/` and had a different mental model around data fetching. App Router is React Server Components native.

**Server component.** A React component that runs only on the server. Its code is never sent to the browser. It can read from the database or the filesystem directly, but it cannot use hooks like `useState` or event handlers like `onClick`. Server components are the default in the App Router.

**Client component.** A React component marked with `'use client'` at the top of the file. Its code ships to the browser so it can use hooks, event handlers, and browser APIs. The senior pattern is to push client components as far down the tree as possible so the rest of the page stays on the server.

**SSR, SSG, ISR.** Three rendering modes the App Router collapses into a unified model. SSR (server-side rendering) renders the page fresh on each request. SSG (static site generation) renders the page at build time and serves the same HTML to everyone until the next build. ISR (incremental static regeneration) is SSG with an expiry, after which the page is regenerated on demand. In v16 the framing has shifted to "is this page dynamic or cached," and you opt into caching with the `'use cache'` directive once `cacheComponents` is enabled.

**Hydration.** The process by which React takes a server-rendered HTML page and attaches event listeners to make it interactive in the browser. The HTML renders first (fast, no JavaScript needed), then the JavaScript bundle loads and "hydrates" the page. Server components produce HTML that requires no hydration; client components do.

**`generateStaticParams`.** A function exported from a dynamic segment route (like `/blog/[slug]`) that returns the list of values the route should prerender at build time. Without it, Next either skips static generation or generates pages lazily on first request, depending on configuration.

**`dynamicParams`.** A route segment config option. When set to `false`, any path not returned by `generateStaticParams` returns a 404 instead of being rendered on demand. Locks the URL space.

**Streaming.** A rendering technique where the server flushes parts of a page to the browser as they become ready instead of waiting for the entire page to render. Powered by React's `Suspense` boundaries. Pairs naturally with PPR.

**Suspense boundary.** A React primitive that lets you wrap an async component and declare what to show while it loads. The framework streams the page shell first and slots the async content in when it resolves.

**`loading.tsx`.** A route-level convention in the App Router. If you put a `loading.tsx` in a route folder, Next wraps the entire page in a Suspense boundary using that file as the fallback. Sugar over manually writing the Suspense.

**Server action.** An async function marked with `'use server'` that runs on the server and can be called directly from a form or client component as if it were a local function. The framework handles the RPC plumbing. Use server actions for mutations triggered by your own UI.

**Route handler.** A `route.ts` file under `app/` that exports `GET`, `POST`, or other HTTP method handlers. Use route handlers for webhooks, external API consumers, file uploads, or anything that needs a stable HTTP endpoint.

**Middleware (now `proxy.ts` in v16).** A file at the project root that runs before every matching request and can redirect, rewrite, or modify the response. In Next 16 the file was renamed to `proxy.ts` and the runtime was forced to Node.js (Edge runtime is no longer supported for this file).

**Edge runtime.** A lightweight JavaScript runtime (based on V8 isolates, not Node.js) that runs at Vercel's edge locations closer to the user. It has a smaller API surface than Node and excludes Node-specific modules like `fs`. Most route handlers in this site use the Node runtime explicitly because they need full Node APIs.

**Turbopack.** The Rust-based bundler that replaced Webpack as the default for Next 16 dev and build. Significantly faster than Webpack for both cold start and incremental compile.

**PPR (Partial Prerendering) / Cache Components.** A v16 feature, opted into by setting `cacheComponents: true` in `next.config.ts`. Lets a single route mix a static shell (served instantly from the CDN) with dynamic holes (streamed in as they resolve). Useful for pages that are mostly the same for everyone but have a few per-user or per-request pieces. This site does not use it because no route has that pattern yet.

**`'use cache'`, `cacheLife`, `cacheTag`.** The v16 caching primitives. `'use cache'` at the top of a function or component tells the compiler to memoize it. `cacheLife` sets the SWR window. `cacheTag` lets you invalidate selectively via `revalidateTag` or `updateTag`. All three were `unstable_` in v15 and lost the prefix in v16.

**`revalidateTag` vs `updateTag`.** Both invalidate cached data by tag. `revalidateTag` marks data stale and refreshes in the background while users see the old value (SWR semantics). `updateTag` expires and refreshes synchronously so the next render sees the new value (read-your-writes semantics, server-action-only).

**RSC payload.** The serialized representation of a React Server Component tree that the framework sends to the browser. It is not HTML; it is a custom format React uses to reconcile server-rendered content with client-side state.

**LCP (Largest Contentful Paint).** The Core Web Vital that measures how long it takes for the largest visible element on a page to render. Common LCP elements are hero images, headline text, or above-the-fold backgrounds. Vercel Speed Insights and Chrome Lighthouse both report it.

**CLS (Cumulative Layout Shift) and INP (Interaction to Next Paint).** The other two Core Web Vitals. CLS measures visual stability (how much content jumps around as the page loads). INP measures responsiveness to user input. Both have direct ranking implications on Google.

**`generateMetadata`.** An async function exported from a page or layout that returns a `Metadata` object. Lets you compute per-page metadata (title, description, OG image, canonical URL) based on the route params.

**`ImageResponse` / `next/og`.** A Next utility that renders JSX directly to a PNG using Satori. Used for generating Open Graph share images dynamically per route without needing a headless browser.

**JSON-LD.** A structured data format Google reads to understand page semantics. Lets you mark a page as an Article, a Person, a BreadcrumbList, etc., so search engines render rich results. Embedded as `<script type="application/ld+json">` in the page head.

---
## Building Kev-O: a grounded RAG chatbot trained on my own writing

URL: https://kevinmurphywebdev.com/blog/building-kev-o
Date: 2026-05-15
Tags: ai, rag, retrieval, streaming, open-source, applied-ai
Excerpt: Kev-O answers questions about my work using only the public corpus I've written. Hybrid BM25 plus cross-encoder rerank, Claude streaming through the Vercel AI SDK, a daily USD cap so it can't blow up my API bill. Three surfaces, one brain. Here's what I built and what choices ended up mattering.

> **Repo:** [github.com/midimurphdesigns/kev-o-ai-search](https://github.com/midimurphdesigns/kev-o-ai-search)
>
> **Live:** [kev-o.kevinmurphywebdev.com](https://kev-o.kevinmurphywebdev.com), plus the ⌘K palette on every page of this site and the inline punch-ins at the foot of every blog post and project case study.
>
> Companion artifacts: [fedbench](/blog/building-fedbench) (RAG eval rigor), [grant-pilot](/blog/building-grant-pilot) (multi-turn agent composition), and the [mdx-corpus](https://github.com/midimurphdesigns/mdx-corpus) primitive this build extracted.

Kev-O is a grounded chatbot that answers questions about my work using only the public writing I've put on the record. Blog posts, project case studies, resume, About page, the READMEs of my open-source repos. He cites his receipts. He refuses to invent.

I built him because the standard portfolio chatbot is a tell. Most of them are GPT with a system prompt that says "you are Kevin's assistant," which produces confident-sounding garbage and gets less interesting every time you talk to it. I wanted the opposite: a surface where the interesting thing is what's actually in the corpus, and where the bot is structurally incapable of saying things I didn't write.

This post is what I built and what choices ended up mattering.

## What's on screen

Three surfaces, all backed by the same retrieval pipeline:

1. **The subdomain.** [kev-o.kevinmurphywebdev.com](https://kev-o.kevinmurphywebdev.com) is a standalone full-page conversation, the URL a visitor can share.
2. **The ⌘K palette.** Every page on this site has a global command palette. Top of the panel is Kev-O. The site-search/jump-to-page list is secondary, deliberately below.
3. **Inline punch-ins.** At the foot of every curated blog post and project case study is an input that's already focused on the page you're reading. Ask about FedNow at the bottom of the FedNow case study and Kev-O grounds his answer in the page first.

All three call the same `/api/kev-o` endpoint on this site. The subdomain is a thin proxy. One brain, three surfaces.

## The retrieval pipeline

```
query
  ↓
BM25 over the full corpus  →  top-20 candidates  (lexical, ~3ms, free)
  ↓
Voyage rerank-2.5          →  top-6 winners      (semantic, ~120ms, ~$0.0002)
  ↓
Optional page-context passage at position 0 for inline punch-ins
  ↓
Claude Sonnet 4.6 streaming via Vercel AI SDK    (temp 0.4, max 800 tokens)
  ↓
text streams to the surface
```

The corpus is about 212 chunks across four sources: this site's MDX content, the resume JSON, the About page prose, and the live READMEs of five OSS repos fetched at build time.

### Why hybrid, not pure embeddings

The dominant signal in the queries Kev-O sees is the literal vocabulary. People type *"what did Kevin do on FedNow"* or *"is grant-pilot federal."* Short, factual, domain-specific. BM25 from 1995 still beats dense-vector retrievers on this query shape because the words ARE the signal.

But BM25 is brittle on paraphrase. *"The federal payments rail"* should match *"FedNow"*; *"the grant-finding agent"* should match *"grant-pilot."* So the second stage is a cross-encoder rerank from Voyage's `voyage-rerank-2.5`, which re-orders the top-20 lexical candidates with full semantic awareness.

This is the same pipeline shape I use in [fedbench](/blog/building-fedbench), where I measured BM25-only vs hybrid vs pure-embedding on a hand-labelled federal-grants benchmark. Hybrid wins on recall@5 by a wide enough margin that it's worth the rerank cost. Pure embeddings lose on the short-query case because they over-smooth the lexical signal.

### What I didn't do

I didn't ship vector embeddings of my own. Two reasons:

1. **The corpus is small** (212 chunks, ~120KB JSON). BM25 over 212 chunks is faster than the network round-trip would be to a vector store.
2. **I had the rerank-only option.** Voyage's reranker takes raw text candidates and a query and returns a relevance score. There's no embedding step on my side. That removes a moving piece and keeps the corpus build pipeline as one MDX → JSON command.

If the corpus were 10x larger this calculation flips. At that point an embedding index is worth standing up, but BM25 is the right first-stage filter regardless.

## The voice problem

The model is Claude Sonnet 4.6. The default Sonnet voice is competent-but-bland. Kev-O needed personality without sliding into the chatbot-bit territory where every response opens with "Great question!"

The system prompt does three things to shape voice:

1. **Anchors who's talking.** Kev-O is described as a competent engineer Kevin asked to handle these conversations. Not "Kevin's AI assistant." Not "a language model." A voice with a stance.
2. **Constrains length.** Default to two short paragraphs. The visitor is evaluating, not reading an essay. If a question genuinely needs more, expand to three. Never lists of bullets unless asked.
3. **Forces citations.** Every passage gets a `[1]`, `[2]` reference in the response. The references point to real URLs into my writing. If Kev-O wants to make a claim, he has to ground it in a passage he can show you.

The constraint that mattered most was the third one. Citations aren't decoration. They're how I get a chatbot to stop hallucinating. If the model can't ground a claim, it has to say so.

## Rate limiting and cost cap

Three layers:

**Per-IP sliding window.** 50 requests per hour via Upstash Redis. Returns 429 with a `Retry-After` header. Plenty for a visitor to evaluate; tight enough that nobody scrapes the model for free.

**Daily USD cap.** Defaults to five dollars per UTC day. The cost is charged post-stream in the AI SDK's `onFinish` callback using the actual token counts the model reports, not an estimate. When the cap is hit, every request gets a friendly *"napping until tomorrow"* response with the seconds-until-midnight retry-after. The cap is the real safeguard. Per-IP limits protect against any one attacker; the USD cap protects against a coordinated swarm I never see.

**Owner bypass.** A separate `/api/kev-o-admin` route accepts `?key=<KEV_O_ADMIN_KEY>` and drops an `HttpOnly`, `SameSite=Strict`, `Secure` cookie that's good for thirty days. The route fails closed if the env var isn't set or is under 24 characters, uses Node's `timingSafeEqual` for the comparison so there's no length-leak oracle, and is per-IP rate-limited at 5 requests per hour BEFORE the key check. That last bit is the one I'm proudest of. An attacker exhausts their guess budget regardless of whether they guessed right. Wrong keys return 404, not 401, so the route is indistinguishable from one that doesn't exist.

## What I extracted along the way

The interesting build artifact wasn't the chatbot. It was a small npm primitive called [mdx-corpus](https://github.com/midimurphdesigns/mdx-corpus) that I pulled out of the corpus build step. It takes a directory of MDX files and emits retrieval-ready JSON chunks: front-matter intact, headings preserved as chunk anchors, code fences kept whole. Three hundred lines of TypeScript, nineteen tests, tsup dual ESM/CJS build. The kind of thing I would have wanted to find before I had to write it.

That extraction is the part I'd most recommend. Building Kev-O didn't teach me much I didn't already know; pulling out the primitive forced me to write the API I would want to consume as a stranger. That's where the design judgment shows up.

## What I'd change

Two things I'd revisit if I shipped this to a real product team:

**Per-corpus prompt tuning.** The voice prompt assumes the corpus is mine and the tone should match. For a multi-tenant version I'd lift the persona description out of the constant and into a build-time argument so the same retrieval pipeline can wear different voices.

**Recall@k as a first-class signal.** The eval suite scores on grounded behavior (does Kev-O cite the right URL, refuse off-topic, redirect on confidential probes). It doesn't yet quantify retrieval recall at k=6 against a hand-labelled gold set. That's the next eval tier. It would let me tune the BM25 candidate count and the rerank top-k against a number instead of a vibe.

Neither of those is hard. Both are *the next move* if this stops being a portfolio artifact and starts being something other people deploy.

## What I did build for safety

The shipping question for a public chatbot grounded in your own writing isn't "does it hallucinate." It's "what happens when someone tries to make it embarrass you on Twitter." So:

A private eval suite runs against the live production endpoint after every deploy. Six categories: grounding (does it cite?), refusal (does it stay on-script?), hallucination (does it invent employers?), persona (does it survive a jailbreak?), prompt injection (can a page override its instructions?), and confidential probes (does it leak the existence of private projects?). Twenty-four prompts. Mostly deterministic matchers; Claude Haiku as judge for the open-ended ones. The suite catches regressions before a real user does.

The corpus build itself has a deny-list scan that fails the Vercel deploy if a private project name ever lands in MDX, resume.json, or a fetched README. Defending earlier in the pipeline is strictly better than defending later in the model.

The eval repo is private. The harness isn't sensitive; the test inputs are. If you publish your jailbreak probes, you've handed an attacker your threat model.

## The thing this is actually proof of

Kev-O is a portfolio object. The point isn't that you should use it. The point is that I built it end-to-end (retrieval pipeline, prompt construction, streaming UI with character-by-character reveal, rate-limit infrastructure, owner-bypass with paranoid security posture), and the result is one URL visitors can click and immediately interact with. Read a case study, then ask the bot a follow-up at the bottom of the page. Same brain. Same voice.

If you're hiring for Applied AI or product engineering and you've gotten this far, [ask Kev-O](https://kev-o.kevinmurphywebdev.com) why I'd be good for the role. He's read the corpus.

---
## mdx-corpus: a tiny package that turns an MDX directory into a retrieval-ready corpus

URL: https://kevinmurphywebdev.com/blog/building-mdx-corpus
Date: 2026-05-15
Tags: ai, rag, open-source, npm, mdx, corpus
Excerpt: While building Kev-O I kept rewriting the same parse-and-chunk step for MDX content. So I pulled it out into a small npm package that does file-in / JSON-out and gets out of the way. No embeddings, no vector store, no LLM. Three responsibilities, sharp boundaries.

> **npm:** `npm install mdx-corpus`
>
> **Repo:** [github.com/midimurphdesigns/mdx-corpus](https://github.com/midimurphdesigns/mdx-corpus)
>
> **Used in:** [Kev-O](https://github.com/midimurphdesigns/kev-o-ai-search), and in this site's own corpus build.
>
> Companion post: [Building Kev-O](/blog/building-kev-o) (the build this got extracted from).

`mdx-corpus` is a small npm package that does the boring, load-bearing part of RAG over a directory of MDX files: parse the frontmatter, strip JSX while keeping the prose inside it, chunk on semantic boundaries, and emit clean passages with deep-link URLs and source metadata. Bring your own embeddings, your own vector store, your own LLM. The package is pure file-in / JSON-out.

I built it because I kept writing the same fifty lines of MDX-handling glue every time I wanted to retrieve over my own writing. It's the kind of thing I would have wanted to find when I went looking. Now I'm publishing it so the next person doesn't have to write it either.

## What it does

Three responsibilities, in order:

1. **Walks one or more source directories and reads every `.md` / `.mdx` file.** Frontmatter is parsed out and kept on the chunk; the body is normalized.
2. **Strips JSX components while preserving their children.** A `<Stat>3</Stat>` becomes the text `3`, not the string `<Stat>3</Stat>` or empty. A `<Callout>The point.</Callout>` becomes `The point.` Embeddings stop being polluted by component names and prop syntax.
3. **Chunks on heading boundaries by default**, falling back to character-budget splits when a section runs long. Each chunk carries its heading so retrieval can cite "from `/blog/x` under section `Y`."

That's the whole package. Roughly three hundred lines of TypeScript, nineteen tests, dual ESM/CJS build via tsup, zero runtime dependencies beyond a small frontmatter parser.

## Why this is its own package

The temptation was to write the parse step inline in Kev-O and move on. Three reasons it earned a separate package:

**It's reusable across my own portfolio.** Kev-O retrieves over my blog and project content. This site's own search index could use the same chunks. A future newsletter archive could too. Three consumers, one source of truth.

**The boundary is sharp.** The package does parse-and-chunk and nothing else. No embeddings, no vector store, no retrieval logic, no LLM orchestration. Those are downstream concerns I want to make different choices about per consumer. A package that owned the whole pipeline would be a framework, and I'd be back to writing glue around it.

**It's small enough to read.** A stranger evaluating it can read the source in fifteen minutes. They'll see that it doesn't reach for unnecessary dependencies and that the test suite covers the gnarly cases (frontmatter with quote characters in values, fenced code blocks containing what looks like a heading, JSX with attributes spanning multiple lines).

## What was tricky

Two surprises during the build that the README doesn't show.

**JSX-with-children is harder than JSX-as-self-closing.** A self-closing `<Stat n="3" />` you can regex out cleanly. A `<Callout title="Note">The body keeps **markdown** inside it.</Callout>` needs to preserve the markdown body while dropping the component shell. The MDX AST handles this correctly, but I didn't want a heavy parser dependency. So the package does a small hand-rolled walk that tracks tag depth and only strips the opening and closing wrappers, leaving everything between them intact for the chunker.

**Heading-based chunking has a long-tail problem.** A blog post with a 4000-word section under one heading produces one giant chunk that blows the token budget. The right answer was a hybrid: chunk on headings first, then for any chunk over the token limit, split on paragraph breaks until under budget. The chunk metadata still carries the original heading so citations remain accurate even after a long section gets split.

## What it does NOT do

By design, the package refuses to grow in three directions:

- **No embedding generation.** Voyage, OpenAI, Cohere, a local model: your call, downstream of this package.
- **No vector storage.** Pgvector, Pinecone, Turbopuffer, a JSON file with BM25 on top: also your call.
- **No retrieval logic.** BM25, cosine similarity, hybrid reranking, cross-encoders: still your call.

This is the discipline that keeps it useful. Every refusal is a thing I don't have to maintain, version, or document. The package stays a sharp tool.

## End-to-end example

```ts
import { buildCorpus } from 'mdx-corpus';
import { writeFile } from 'node:fs/promises';

const corpus = await buildCorpus({
  sources: [
    { dir: './content/blog',     baseUrl: '/blog',      kind: 'blog' },
    { dir: './content/projects', baseUrl: '/portfolio', kind: 'project' },
  ],
  chunkBy: 'heading',
  maxChunkTokens: 500,
  includeFrontmatter: ['title', 'date', 'tags'],
});

await writeFile('corpus.json', JSON.stringify(corpus));
```

You hand the output to your embedder, push the vectors into your store, retrieve at query time. The package is upstream of every interesting choice you get to make.

## How Kev-O uses it

In Kev-O's case, the corpus is built at deploy time via Next.js's `prebuild` step. About two hundred chunks across four sources: this blog, the project case studies, my resume JSON, and the READMEs of five open-source repos pulled at build time. The resulting `corpus.json` is committed to the deploy artifact and read by the API route at request time. BM25 runs in-memory over those two hundred chunks in under five milliseconds. Voyage's rerank-2.5 narrows the top twenty candidates to the top six. Claude generates from there.

The whole retrieval pipeline is maybe one hundred lines of code on top of `mdx-corpus`. That's the package doing its job: get the parse-and-chunk right so I can focus on the parts that actually matter to the visitor.

## What I might add later

I'm deliberately keeping the API small until I have a real second consumer. The two things on the maybe-someday list:

- **A `prune` option** that drops chunks under a minimum token count (currently they just emit; consumers filter). Probably the next thing I add the first time I rebuild against a corpus with a lot of one-paragraph posts.
- **An `onChunk` hook** so consumers can attach computed fields (read time, language detection, tags inferred from content) at parse time instead of post-processing. Useful but I want to see two callers ask for it before I commit to the surface.

Most package design failures I've watched come from adding the surface before the second consumer asks. So the package stays small until the second consumer is real.

## The recommendation, if you're building RAG over your own writing

Use `mdx-corpus` if your corpus is MDX files in a Next.js / Astro / Remix repo. Don't use it if your corpus is raw markdown and you're not on the JSX side of the world. The whole reason this package exists is the JSX-component handling, which is what makes MDX content uniquely annoying to embed cleanly.

If `mdx-corpus` doesn't fit your shape, the relevant thing to copy is the chunking strategy: heading first, paragraph-fallback for long sections, metadata carried through so retrieval can cite the right URL. That's the load-bearing part.

---
## streamfield: a small library for AI streams that don't look broken

URL: https://kevinmurphywebdev.com/blog/building-streamfield
Date: 2026-05-14
Tags: ai, react, vercel, open-source, frontend
Excerpt: When the Vercel AI SDK streams a structured response, the fields flicker and snap into place as they arrive. streamfield is a tiny React library that fixes that. One component, four props, no dependencies.

> **npm:** `npm install streamfield`
>
> **Repo:** [github.com/midimurphdesigns/streamfield](https://github.com/midimurphdesigns/streamfield)
>
> **Live playground:** [streamfield.kevinmurphywebdev.com](https://streamfield.kevinmurphywebdev.com)

## What streamfield is

A small React library that takes the partial-object stream from the Vercel AI SDK and renders it without the flicker.

## The problem

The Vercel AI SDK's `streamObject` re-sends the whole JSON object every chunk, so naive rendering rewrites the page on every chunk: the title flashes in, the bullets pop into the DOM, the summary keeps overwriting itself.

CSS transitions can't fix this. The DOM elements existed before the stream; only their text content changed. CSS animates property changes, not innerText swaps.

## What streamfield does

For every field in your object, streamfield diffs the latest snapshot against the previous one and tells you which of three states the field is in right now:

- **pending** means the field hasn't appeared yet. Use this state to reserve space or show a skeleton so layout doesn't jump when the field arrives.
- **streaming** means the field is currently being written. Use this state to draw the user's eye to it (a shimmer sweep, an underline that grows with the text, a soft blur that clears as content lands).
- **complete** means the field has stopped changing. Use this state to fire a sound, hide the cursor, mark the section as done, or trigger any action that depends on the field being finalized.

That's the whole value proposition. Three states per field, exposed as a data attribute you can style or as a render-prop value you can act on.

## End-to-end example with streamObject

```tsx
// app/api/suggest/route.ts
import { streamObject } from 'ai';
import { gateway } from '@ai-sdk/gateway';
import { z } from 'zod';

export async function POST(req: Request) {
  const { prompt } = await req.json();

  const result = streamObject({
    model: gateway('openai/gpt-4o-mini'),
    schema: z.object({
      title: z.string(),
      summary: z.string(),
      bullets: z.array(z.string()),
    }),
    prompt,
  });

  return result.toTextStreamResponse();
}
```

```tsx
// app/page.tsx
'use client';

import { useState } from 'react';
import { experimental_useObject as useObject } from 'ai/react';
import { z } from 'zod';
import { StreamingReveal } from 'streamfield';
import 'streamfield/styles.css'; // optional defaults; skip for custom CSS

const schema = z.object({
  title: z.string(),
  summary: z.string(),
  bullets: z.array(z.string()),
});

type Suggestion = z.infer<typeof schema>;

export default function Page() {
  const { object, submit, isLoading } = useObject({
    api: '/api/suggest',
    schema,
  });

  return (
    <>
      <button onClick={() => submit({ prompt: 'top regions by ARR' })}>
        Ask
      </button>

      <StreamingReveal<Suggestion>
        stream={object ?? {}}
        done={!isLoading}
        variant="cascade"
      >
        {(f) => (
          <article>
            <h2 data-streamfield-state={f.title?.state}>
              {f.title?.value}
            </h2>
            <p data-streamfield-state={f.summary?.state}>
              {f.summary?.value}
            </p>
            <ul data-streamfield-state={f.bullets?.state}>
              {f.bullets?.value?.map((b, i) => <li key={i}>{b}</li>)}
            </ul>
          </article>
        )}
      </StreamingReveal>
    </>
  );
}
```

What's happening:

- `useObject` from the Vercel AI SDK calls your `/api/suggest` route, streams the response, and exposes the current partial object as `object`.
- That partial gets handed to `<StreamingReveal>` as `stream`, along with `done={!isLoading}` so the component knows when the stream finishes.
- Inside the render-prop, every field in your schema shows up as `f.<fieldName>` with a `state` and a `value`. Stamp the state onto the element via `data-streamfield-state` and style it however you like.
- If you imported `streamfield/styles.css`, the three variants (`cascade`, `shimmer`, `underline-fill`) handle the animation for you.

## Why not just use a CSS animation on each field?

Two reasons that hold up under scrutiny:

1. **CSS can't see when a field starts vs. when it finishes.** The HTML element exists before the stream, exists during the stream, and exists after. Without a state attribute, your CSS has nothing to react to. You'd have to track field lifecycle in JavaScript anyway, at which point you've reimplemented the diff streamfield does for you.

2. **The Vercel AI SDK gives you a snapshot, not a diff.** Every chunk hands you the whole object again with whatever's filled in. React's reconciler doesn't know which fields changed, so it just rewrites every text node. Without a per-field state derived from snapshot comparison, you can't tell "this field is mid-write" from "this field is done" in any reliable way.

streamfield does the snapshot-comparison work once, in 70 lines, and exposes the result. You don't have to do it again in every consumer.

## What it isn't

A few honest limits:

- It's for structured streams. If you're streaming raw text token by token, the AI SDK already handles that well. Use `streamText`, not this.
- It's React only.
- It doesn't include an animation library. If you want spring physics on field reveals, bring Framer Motion or your own CSS. streamfield only tells you which state each field is in.
- It won't help if you're not using `streamObject` (or another source that emits partial objects). For something like `useChat` where you're streaming a single message string, you don't need this.

## Install it

```bash
npm install streamfield
```

The live playground at [streamfield.kevinmurphywebdev.com](https://streamfield.kevinmurphywebdev.com) shows the same partial rendered with and without the library, side by side. Scrub the slider and the difference makes the case.

Open source on [GitHub](https://github.com/midimurphdesigns/streamfield). Issues and PRs welcome. If you ship something with it, message me on [LinkedIn](https://www.linkedin.com/in/midimurphdesigns/), [X](https://x.com/midimurph), or [Bluesky](https://bsky.app/profile/midimurph.bsky.social).

---
## Building tablesalt: a CSV agent where the answer IS the UI

URL: https://kevinmurphywebdev.com/blog/building-tablesalt
Date: 2026-05-14
Tags: ai, generative-ui, vercel, evals, open-source
Excerpt: tablesalt is an in-browser data agent. You drop a CSV, ask a question, and the agent renders the answer as the right kind of UI: a chart, a stat card, a table, or a list. No chat bubbles.

> **Repo:** [github.com/midimurphdesigns/tablesalt](https://github.com/midimurphdesigns/tablesalt)
>
> **Live demo:** [tablesalt.kevinmurphywebdev.com](https://tablesalt.kevinmurphywebdev.com)

## What tablesalt is

You drop a CSV into the browser. You ask a question. The agent decides what kind of answer it wants to be (a chart, a single number, a table, a list), writes one SQL query, runs the query against your file in the browser, and renders the result.

That's the whole product.

## What other AI-for-data demos do, and why it falls flat

Open almost any AI-for-data demo today and you get the same shape: a chat input on one side, a chat reply on the other. You ask "what are my top five regions by revenue?" and the model writes back "Sure. Your top five regions by revenue are: North America at $1.2M, Europe at..." in a chat bubble.

This works as a tutorial. It doesn't work as a product. The user has to read a paragraph to see the answer. The answer should *be* the chart.

## What tablesalt does differently

A few concrete choices set it apart:

- **The answer is a real UI element, not a chat reply.** The agent picks one of five render kinds (chart, stat card, line chart, table, list) and the result lands as that thing. No prose wrapper.
- **You watch the agent think.** Before the SQL runs, four short reasoning steps stream onto the screen one at a time: what the agent noticed about your data, what kind of answer it picked, the query it wrote, and what it checked before running. It feels like a person working, not a model dumping JSON.
- **The eval scoreboard is on the front page, and you press the button.** Twelve hand-labelled questions run against the live model in front of you. The accuracy numbers are real. The per-case cost in dollars is on the screen. No hidden benchmark, no "we tested it ourselves once, trust us."
- **Nothing leaves your browser.** DuckDB-WASM parses and queries the CSV locally. No upload step, no privacy story to write, no signup wall.

## How it's built, briefly

Next.js 16 App Router with two edge API routes. The first sends the user's question to the model. The second runs the eval. Both use `streamObject` from the Vercel AI SDK with a Zod schema, which means the four reasoning steps and the final SQL come back as one progressively-completing JSON object. The streaming reveal of those fields is handled by [streamfield](/blog/building-streamfield), a small library I extracted from tablesalt and published to npm.

Models are routed through the Vercel AI Gateway. One environment variable replaces every per-provider API key. Switching between `openai/gpt-4o-mini`, `gpt-4o`, Claude Haiku, and Claude Sonnet during development was a one-line config change. I picked `gpt-4o-mini` because the eval scoreboard said it was the cheapest model that got the answers right. That decision is reproducible on the page.

## What's deliberately not in v0.1

The post would be dishonest if it didn't name the limits.

- No auth. No saved sessions. No multi-file joins.
- No write-back to your CSV. The SQL guard rejects anything that isn't a SELECT.
- One model call per question. The agent's reasoning trace makes it *look* like a multi-step agent, but it's really one round-trip with structured intermediate fields. A real tool-use loop is a v0.2 decision if the simpler version stops being enough.

tablesalt is open source on [GitHub](https://github.com/midimurphdesigns/tablesalt) and live at [tablesalt.kevinmurphywebdev.com](https://tablesalt.kevinmurphywebdev.com). The fastest way to evaluate it is the live demo. Drop one of the sample CSVs, ask a question, and see what lands.

---
## Building grant-pilot: a multi-turn agent that orchestrates three sub-agents over real federal data

URL: https://kevinmurphywebdev.com/blog/building-grant-pilot
Date: 2026-05-10
Tags: ai, agents, sub-agents, orchestration, open-source
Excerpt: A multi-turn agent that finds federal grants for small businesses and nonprofits. A planner orchestrates three sub-agents (discovery, eligibility, drafter) over real federal data, with a hard daily budget cap, atomic spend reservation, and a planner that never throws. Here's what I built and what shipping it taught me.

> **Repo:** [github.com/midimurphdesigns/grant-pilot](https://github.com/midimurphdesigns/grant-pilot)
>
> **Live demo:** [grant-pilot.kevinmurphywebdev.com](https://grant-pilot.kevinmurphywebdev.com)
>
> Companion posts: [fedbench](/blog/building-fedbench) (eval rigor) and [fieldops-mcp](/blog/building-fieldops-mcp) (agent tool design).

[grant-pilot](https://github.com/midimurphdesigns/grant-pilot) is the third of three companion artifacts with [fedbench](https://github.com/midimurphdesigns/fedbench) and [fieldops-mcp](https://github.com/midimurphdesigns/fieldops-mcp). fedbench is about measuring whether an agent is right. fieldops-mcp is about shaping what the agent can do at all. grant-pilot is what those look like composed: an agent that has to actually run a workflow, multi-turn, with real APIs, with structured failures, with a hosted demo strangers can run without trusting me with credentials.

## What it does

A small business or nonprofit picks one of five intents (e.g. *"I run a 12-person construction firm in Arizona, what infrastructure-related grants might fit?"*). The agent does three things a grants consultant does:

1. **Discovery.** Derives a keyword query from the intent, runs a federal-grants keyword search, ranks 5 candidates 0–100 with one-line rationale per candidate.
2. **Eligibility.** For the top 3 candidates, fetches the full grant record, optionally checks the applicant's SAM registration status against the federal entity-registration system, and returns `pass` / `fail` / `uncertain` with reasons grounded in the eligibility text.
3. **Drafter.** For the highest-ranked candidate that passed (or the highest "uncertain" if none passed cleanly), produces a structured application skeleton: section headings, per-section guidance, and applicant-prompts. Plus a watch-outs list of pitfalls grounded in the grant's eligibility.

Three sub-agents, dispatched by a planner, composed into a transcript with provenance: which model answered each turn, how long it took, what it cost. Total cost per run: about five cents. Total wall time: about a minute.

## Why three sub-agents, not five

The temptation is to add a "budget builder", a "compliance reviewer", a "prior-art search". Each of those is plausible. None of them is necessary to demonstrate the *shape*: sub-agent orchestration plus tool selection plus failure recovery. Adding them would be framework creep that dilutes the headline.

Three is the smallest count that proves the shape. Discovery uses one tool (search). Eligibility uses two (detail-fetch + SAM lookup) and exercises a hard-gate hoist where a deterministic SAM check overrides the model's verdict. Drafter uses one (detail-fetch) and exercises the most opinionated design choice in the project: it doesn't write prose.

## The drafter doesn't write prose

This is the call I expect to get the most pushback on. "AI writes your grant application" is an easier marketing line. It would also be a worse product.

Federal grant applications need verifiable claims and applicant-specific voice. The agent will hallucinate both. So the drafter emits a structured skeleton (sections, guidance per section, questions the applicant must answer) instead of pretending it can speak for the organization. From a real run for the Arizona construction firm:

```
# Project Approach
  ↳ Construction activities, sequence, Davis-Bacon compliance plan...
  - What construction activities will you perform, and in what sequence?
  - What codes, standards, or federal requirements (Davis-Bacon Act,
    Buy American, environmental review) govern this project?
  - How will you manage subcontractors, and what portion of the work
    will your 12-person team self-perform versus sub out?

watch-outs:
  ! For-profit construction firms must apply through a state/local lead
    applicant. Confirm partnership before further investment.
  ! SAM.gov registration must be active at submission AND throughout
    the period of performance.
  ! Davis-Bacon prevailing wage compliance is mandatory on federally-
    funded construction.
```

That's the shape AI is actually good at: compressing the reading work, surfacing what a NOFO asks for, structuring the response. Honest about what it isn't doing.

## The hard gate the model can't override

If the user profile contains a UEI, the eligibility sub-agent checks the applicant's SAM registration status. If the registration is anything other than active, that fact gets prepended to the verdict's blockers regardless of what the model said.

Why bypass the model? Because federal grants categorically do not award to unregistered entities. Letting the model "decide" creates a path where it answers `pass` despite a fatal disqualifier. The hoist makes the constraint structural rather than emergent. It's the only place in the system where a deterministic check overrides the verdict, and that's exactly the kind of thing that should bypass the LLM, not depend on it.

## How prompt design and external APIs are coupled

The first version of the discovery prompt produced queries like:

> "commercial construction infrastructure small contractor Arizona Phoenix metro"

Zero useful hits.

The federal-grants keyword index does **strict AND-matching**: every term has to appear in the opportunity title or synopsis. Long queries get zero matches. After tightening the prompt to emit 2 to 4 broad nouns and explicitly drop geography (handled at eligibility time, not in the keyword index), the same intent produced a real shortlist:

```
52  PWEAA2023      FY 2025 EDA Public Works and Economic Adjustment Assistance
45  DHS-25-MT-047  FEMA Building Resilient Infrastructure and Communities (BRIC)
38  GR-RDC-25-001  RESTORE Act Direct Component
35  HE125426R5001  Military-Connected Schools Construction
30  VA-GRANTS-...  State Veterans Home Construction Grant Program
```

The lesson, again: prompt design and external-API behavior are coupled. You can't tune one without understanding the other. The "AI part" is not separable from the "API part".

## Why the planner never throws

Sub-agent failures, search-API errors, JSON parse failures: every one of them surfaces as a structured `TranscriptStep` entry the renderer and the recorder both consume. The planner has zero `try/catch` blocks at the call-site level. Errors are values, not exceptions.

This is what production-shape error handling looks like in agent code. The hosted demo doesn't have to wrap calls in `try/catch`. The recording layer doesn't have to handle partial JSON. The eval scorer can pattern-match on `result.kind === "error"` without inspecting types. Failures compose; exceptions don't.

## The hosted demo, the custom-intent path, and the $3/day cap

I want strangers to be able to run this agent without cloning anything, registering for keys, or trusting me with their credentials. So there's a [hosted demo](https://grant-pilot.kevinmurphywebdev.com): pick a preset intent or write your own funding-need description, watch the transcript stream in real time, see the same provenance the local CLI shows.

But the moment a public website hits an LLM provider, it's a cost surface. So the demo is hardened in four layers:

- **5 preset intents.** Verified, recorded, and each has a fallback recording when budget runs out.
- **Custom intent + structured custom profile.** Visitors describe their own scenario (20–600 chars) and fill in their own profile: NAICS code, state, ZIP, employee count, annual revenue, ownership designations, entity type, years in operation. Every structured field is bounded by an enum or a regex or a number range, so the injection surface is identical to the preset case. The two free-text fields (intent and an optional mission description) are length-capped and filtered for jailbreak phrases before any model call.
- **Per-IP rate limit.** Five runs per hour via Upstash. Enough headroom for a curious visitor; not enough for script-driven abuse.
- **Daily budget cap with transparent live readout.** $3/day, ~45 demo runs. The page shows the running total and color-codes the bar (green to yellow to red) so visitors know what state they're in. Preset intents fall back to the recorded run when the cap is hit; custom intents return a 503 with a banner explaining the cap.

None of these guardrails are flashy. All of them are the kind of thing that has to be there before a public AI demo can responsibly stay public, and the visible budget pill is the kind of trust signal hosted AI demos almost never bother with.

## Composition with the prior two projects

The fallback ladder in `src/agent/fallback-ladder.ts` is ported directly from fedbench. Sonnet 4.6 primary, Haiku 4.5 fallback, with provenance returned on every call so the transcript can show which rung answered. The MCP-style tool registry mirrors the fieldops-mcp tool template: same `{ name, description, input_schema }` shape so a reviewer who's read fieldops-mcp recognizes the pattern instantly. The recording layer (`bun run demo` reads the JSONL with no API key needed) is fedbench's pattern, transplanted.

Three repos, composed on purpose. Each one demonstrates one shape; reading all three shows a developer who builds in patterns rather than reinventing per project.

## Migration to Vercel AI SDK, and what changed

grant-pilot shipped on the raw Anthropic SDK. After the first demo ran end to end and the recordings looked right, I ported the agent stack to Vercel's AI SDK. The blog post you're reading was already drafted; I'm appending this section rather than rewriting because the order matters. Ship the substance first. Port to the abstraction when the case for it is clear. Both are defensible. The order is part of the story.

The case for the port, in priority order:

1. **`generateObject` replaces hand-rolled JSON parsing.** The old discovery and eligibility sub-agents lived inside a four-step ritual: write a system prompt that begs the model to "output JSON only, no markdown fences", call the model, strip fences with a regex, run `JSON.parse`, then `Schema.parse`. Four chances for the parse to fail silently or noisily. `generateObject({ schema, system, prompt, maxRetries: 2 })` collapses that into one call. The same Zod schemas plug in directly. The SDK constrains the model's output to match, validates on completion, and retries on schema failure inside the same request. The two sub-agents lost their `parseJsonLoose` helper and read about thirty percent shorter as a result.

2. **`streamObject` unlocks progressive UI for any structured output.** Drafter was the first sub-agent to migrate because the value was loudest there: section headings, guidance text, applicant prompts, and watch-outs filling in one block at a time felt right. But the same trick applies to Discovery's ranked shortlist and Eligibility's verdict. Both now use `streamObject({ schema, system, prompt })`, which exposes a `partialObjectStream` of `DeepPartial<schema>` updates. The endpoint emits a `discovery-partial` / `eligibility-partial` / `draft-partial` NDJSON event per update; the transcript renders streaming preview tiles that the final tiles replace once each sub-agent finishes. Same structured contract, no two-call cost, and the perceived latency drops from "six-second spinner" to "the answer is forming in front of me."

3. **Provider abstraction is real value even with one provider.** `src/provider.ts` is now a five-line module exporting `anthropic = createAnthropic({ apiKey })`. Every model call in the agent stack reaches through that single source of truth. If I wanted OpenAI tomorrow, I'd add `import { openai } from "@ai-sdk/openai"` and change one line. The agent code is identical. That's not a feature I need today, but the structural property of "provider choice is decided in one place" is the right shape regardless of whether I exercise it.

4. **It's what Vercel customers use.** This is the unsentimental reason. The AI SDK is the canonical primitive on Vercel's platform. A product engineer walking into a customer codebase on Vercel will see this stack, not the raw Anthropic SDK. Building against it now means the patterns transfer cleanly into customer work.

### Three streaming primitives, three jobs

The temptation, after migrating, is to use `streamObject` everywhere. I didn't. Each of the AI SDK's structured-output primitives is doing a different job in this codebase, and the choice between them is one of the more honest design questions in agent UX.

- **`streamText` with tools and `maxSteps`** runs the Drafter sub-agent's prose tier. Drafter writes English sentences inside structured fields and may call `grant_detail` mid-generation to re-check the opportunity record. `streamText` is the only primitive that streams tokens and supports tool-use loops in the same call. Wrong primitive for structured output without prose; right primitive when prose is what's running through it.

- **`streamObject` with a Zod schema** runs Discovery's ranking step and Eligibility's verdict step. Both have structured outputs whose individual entries (a ranked candidate, a reason, a blocker) are independently meaningful before the full object lands. The user gains real signal from watching the shortlist build entry-by-entry rather than waiting six seconds for the whole list to drop. The schema still constrains the final shape; the partials stream against it.

- **`generateObject`** runs Discovery's cheap derive-query step: a two-field object (`keyword`, `rationale`) that resolves in about a second on small output. Streaming a two-field object would deliver no perceived-latency win and adds complexity in the consumer. Plain `generateObject` is the right default; streaming is the upgrade you pay for when the user can see the answer forming.

The shape of that decision is what I find most interesting about working in this layer. The SDK gives you three knobs that look interchangeable in the docs and are not interchangeable in practice. Picking the right knob for each call is most of the job.

What stayed: the code-based planner. The "deterministic boundary that never throws, routes failures as values" story is central to this project, and I want it audible. The planner orchestrates three discrete sub-agent calls; the LLM doesn't drive the orchestration. The AI SDK's `streamText({ tools, maxSteps })` lets you build an LLM-driven planner with native tool-use loops, and that's the right primitive for some products. It's the wrong primitive for one where determinism at the orchestration layer is the whole pitch. Recognizing when not to use a feature is part of the work.

The fallback ladder stayed too, just with a different call shape. The old signature took an Anthropic client and a system+user prompt; the new one takes a callback parametric on the AI SDK's `LanguageModel` type. Sub-agents pass in their own `generateObject` or `streamObject` call; the ladder owns the retry-on-overloaded policy and cost math. Same fallback rungs (Sonnet 4.6 primary, Haiku 4.5 fallback). Same cost-per-million-tokens math. Different surface.

One real bug the migration surfaced. On one of the five preset intents, both ladder rungs occasionally fail with `"No object generated: response did not match schema"` because the model's output sometimes violates the schema in a way `generateObject` rejects. The old code was probably accepting the same kind of malformed output silently and recovering through `parseJsonLoose`'s leniency. The new code fails loudly and routes the failure as a structured value to the planner, which surfaces it as a "discovery failed" decision step rather than crashing. That's the agentic-correctness story this project has been telling. Better to fail visibly than succeed accidentally.

The recordings were re-captured against the new stack. Total spend across all five intents: about thirty-six cents.

## How these skills transfer

Federal grants are the example. The shape applies anywhere a buyer's workflow is bureaucratic, multi-step, and grounded in real systems of record. Real bottlenecks this shape addresses:

- **Bureaucratic workflows that buyers don't have time to navigate themselves.** Tax filings, compliance audits, healthcare prior auths, B2B procurement, immigration, insurance claims. The planner-with-sub-agents shape compresses days of reading into a single transcript with a recommended next step, and shows the work, so the buyer can verify before acting.
- **Production agents going over budget.** Every public AI feature is a cost surface the moment it ships. The daily-cap counter with graceful replay fallback is the pattern that turns "we shut the demo down at 11am because someone went viral" into "we serve a recorded run with a banner and the metric stays under cap." Generalizes to any per-team or per-tenant cost cap.
- **Prompt-injection on free-text customer fields.** Anywhere a customer types into an agent (support chat, intake forms, "describe your situation" textareas) the structured fields are the injection surface. Bounded enums + length caps + heuristic pre-filters neutralize that surface without giving up the ability to take real input.
- **Fragile multi-step agents.** The planner never throws: sub-agent failures, API errors, JSON parse failures all become structured `TranscriptStep` entries. That's what production agent code actually looks like once the demo gets real traffic. Errors are values you can route on; exceptions kill the request.
- **Enterprise compliance officers asking for hard gates.** SAM registration status is a categorical disqualifier here; legal has equivalents in every regulated vertical. A deterministic check that overrides the LLM verdict is the difference between an agent that occasionally lies to compliance and an agent that compliance approves to ship.

These are the conversations a federal-grants demo opens that an abstract "I built an agent" pitch can't.

## What the three companion artifacts prove

[fedbench](/blog/building-fedbench) shows I can measure whether an agent is right. [fieldops-mcp](/blog/building-fieldops-mcp) shows I can shape what the agent can do at all. grant-pilot shows I can compose those into a working multi-turn workflow that fails honestly, falls back gracefully, and lives publicly without lighting money on fire.

Three public, MIT-licensed artifacts, with sample transcripts and live demos. Read the source. The proof is in the code.

## Why I built this one, and what I'm hoping it starts

A lot of the work I want to be doing more of in the next few years sits at the boundary between a real customer's workflow and the agent that helps them run it. The skill I find most interesting in 2026 is composing tools, sub-agents, and evals into a multi-turn system that does honest work for a real user. Picking when to fall back, when to refuse, when to surface uncertainty, where the human stays in the loop. Most of the value an agent delivers in production is decided in those choices, before it touches a model.

If you're working on agent-shaped problems (your own product, your own team, anywhere in your network, inside Deloitte's AI practice or at any of the AI-native companies building this kind of system) I'd genuinely enjoy a conversation. The version I find most useful is usually the smallest: one specific workflow, what you composed, what you almost shipped and pulled back. I'd rather swap notes on what's actually working than trade abstractions about agents in general.

Easiest way to reach me is [the contact page](/contact) on this site, or just connect on LinkedIn. The repo is at [github.com/midimurphdesigns/grant-pilot](https://github.com/midimurphdesigns/grant-pilot), the live demo is at [grant-pilot.kevinmurphywebdev.com](https://grant-pilot.kevinmurphywebdev.com), and the docs in there go deeper than this post does: an architecture overview, an ADR log, design notes, and a memory directory of the calls and quirks the system surfaced during build.

---
## Building fedbench: an LLM eval harness for grounded Q&A

URL: https://kevinmurphywebdev.com/blog/building-fedbench
Date: 2026-05-09
Tags: ai, evals, rag, open-source
Excerpt: Most LLM agents that read documents and answer questions fail in three specific ways. fedbench measures all three. Here's what it does, why I built it, and what I learned shipping it.

> **Repo:** [github.com/midimurphdesigns/fedbench](https://github.com/midimurphdesigns/fedbench)
>
> **Live demo:** [fedbench.kevinmurphywebdev.com](https://fedbench.kevinmurphywebdev.com)
>
> Companion posts: [fieldops-mcp](/blog/building-fieldops-mcp) (agent tool design) and [grant-pilot](/blog/building-grant-pilot) (sub-agent orchestration).

I spent a weekend building [fedbench](https://github.com/midimurphdesigns/fedbench), an open-source evaluation harness for LLM agents that read documents and answer questions about them. It's MIT-licensed, runs end-to-end on a laptop, and ships with two side-by-side public corpora: three Medicare publications and three OSHA workplace-safety publications. Same agent, same retrieval, same judge, different language shape, different signal.

This post is the version I'd want to read if someone else had built it. What it does, how it's put together, why the decisions went the way they did, and what it actually says about the kind of work I want to be doing.

## The problem fedbench measures

If you've used an AI assistant to answer questions about a long document (a policy PDF, a contract, a manual) you've probably seen one of three failure modes:

- **Made-up citations.** The agent confidently cites "page 47" for a fact that's actually on page 12, or on no page at all.
- **Confidently wrong answers.** The agent paraphrases the document but quietly distorts a number, a deadline, or an eligibility rule.
- **Guessing instead of refusing.** When the answer isn't in the document, the agent invents one rather than saying "I don't see that here."

These are easy to miss in a demo, where the person asking already knows the answer. They get expensive at scale, in the hands of users who can't easily check the source: caseworkers, paralegals, claims adjusters, anyone whose job involves reading dense documents and answering questions about them.

fedbench makes those three failures measurable. That's the whole pitch.

## What it actually does

The harness has three layers, each one named after the thing it measures:

- **Citation accuracy.** Does every answer cite a real page that contains the claim? This is checked deterministically: the documents are parsed into chunks tagged with page numbers, and the agent's claimed citation has to match an actual chunk.
- **Citation faithfulness.** Even if the page exists, does it actually support the answer? This one isn't deterministic. fedbench uses a stronger model (Claude Opus 4.7) to read the cited page and judge whether the agent's answer is supported, partially supported, or not supported at all. The judge is always a more capable model than the agent, never the same model grading itself.
- **Refusal discipline.** When asked something the documents don't contain, does the agent refuse, or does it guess? Tested with a held-out set of questions whose answers aren't in the documents at all.

A run produces a structured report: per-question verdict, cost in dollars and tokens, latency at p50/p95, and which model on the fallback ladder produced each answer. End-to-end cost runs about 2 to 3 cents per question.

## Two domains, same harness, what changed when I added OSHA

The version I shipped first only had the Medicare corpus. Adding the OSHA corpus a few days later was the test of whether the harness was actually general or just demo-shaped. Same agent, same retrieval, same judge, same scoring rules. Only the source documents and the ground-truth questions changed.

The numbers on both runs:

| | Medicare (11 pairs) | OSHA (10 pairs) |
| --- | --- | --- |
| Pairs that pass the gate | 11 / 11 | 10 / 10 |
| Citation existence (pass / skip) | 8 / 0 | 5 / 3 |
| Judge (faithful) | 6 / 8 | 8 / 8 |
| Judge (partially-faithful) | 2 / 8 | 0 / 8 |
| Out-of-corpus refusal | 3 / 3 | 2 / 2 |
| Cost per pair | ~$0.029 | ~$0.024 |
| Latency p50 / p95 | 2.2s / 4.2s | 2.0s / 2.5s |

The interesting line is the citation-existence row. On Medicare, the deterministic checker matched all 8 in-corpus answers against their cited pages. On OSHA, only 5 matched. The other 3 had to defer to the LLM judge.

That's not a bug; it's the harness telling you something true about the domains. Medicare answers are number-dense ("$1,736 deductible", "8-month enrollment window"), so the regex-driven token extractor has plenty to grab. OSHA answers are procedural ("one warden per 20 employees", "treatment within 3 to 4 minutes", "10 or fewer employees may communicate orally"). The *shape* of the answer is different, the load-bearing tokens are different, and the deterministic layer naturally has less to work with. The judge picks up the slack.

The lesson I took from the side-by-side: a one-domain harness lets you show that an eval pipeline *can* run end-to-end. A two-domain harness lets you show that the same pipeline responds usefully to different language shapes, and that the per-layer contribution shifts when the domain shifts. That second observation, I think, is the actually-useful one.

## How it's built

The stack is deliberately small. Bun for the runtime (fast, native TypeScript, no transpile step). TypeScript in strict mode. The Anthropic SDK for both the agent and the judge. Zero database: the document index is a JSON file, the eval set is JSONL, the runs write to disk. No vector database, no embedding API, no SaaS account required to fork it and run.

Two pieces are worth calling out, because they're where most of the design judgment lives:

**Retrieval is BM25, not embeddings.** This was the most counterintuitive call. The default move in 2026 is to reach for embeddings and a vector database for anything RAG-shaped. I went the other way. Caseworker-style queries are short, factual, and dominated by literal terms ("Part B premium," "10 days," "8-month period"). Those queries' relevance signal *is* the literal terms, which is exactly what BM25 ranks on. Embeddings shine on paraphrase-heavy or cross-lingual workloads; this isn't either. BM25 also has zero infrastructure cost, which keeps the "fork it and run it" property intact. If a future eval shows BM25 hurting accuracy, the upgrade path is hybrid retrieval, but that's a measurement to earn, not an assumption to start with.

**There's a fallback ladder, not a single model.** Production AI deployments need a degradation path. The primary model can rate-limit, error, or be slow on a given call. fedbench encodes the ladder explicitly: Sonnet 4.6 first (best instruction-following at moderate cost), Haiku 4.5 if the primary fails (~5x cheaper, ~2x faster, lower quality), and a third rung for open-weights as a last resort. The cascade is conservative: only true provider failures (rate limits, 5xx errors, network) trigger a fallback. A 400-class error means there's a bug in the harness, not a problem with the provider, and the ladder stops so the bug isn't masked. Every answer reports which rung produced it, with the full provenance of any earlier rungs that failed.

The fallback ladder is the piece that surprises people the most. Most demo agents don't have one. Most production agents need one.

## What it demonstrates, and how this relates to where I'm pointing

I built fedbench because I wanted to be honest with myself about what kind of engineer I am at this point. I've spent the last several years shipping React and TypeScript at federal scale (IRS.gov, FedNow, the Michigan unemployment system). That's real production experience, and I'm not running away from it. But the work I want to be doing next sits a layer up: deploying AI systems into customer environments, owning the whole loop from "what does this team actually need" to "is the agent good enough to ship," and proving the answer to the second question with measurements rather than vibes.

The shape is the same across the role names that touch this work: production AI judgment, customer-facing delivery, and the engineering rigor to make the system auditable rather than just demoable.

fedbench is the cleanest way I can show that work. The skills it makes visible:

- Designing an evaluation pipeline that distinguishes deterministic checks (cheap, exact) from judgment calls (expensive, approximate), and using the right tool for each.
- Picking retrieval strategies based on the actual query distribution rather than the hyped default.
- Building seams in the right places so a second domain is a config change, not a fork, and so the harness can *measure* domain-by-domain differences rather than assume them.
- Separating the API-cost surface from the rest of the pipeline cleanly enough that the same scoring code can run on live model output or on a checked-in recording, with no branching at the call site. That's what makes the no-key demo path possible without forking the runner.
- Building a fallback ladder with explicit cascade rules, because production AI systems can't have a single point of failure pretending to be a measurement.
- Treating cost and latency as first-class metrics, not afterthoughts. Every answer carries its dollar cost and its rung-of-origin.
- Writing it all in TypeScript with strict types, real tests, and a CI pipeline. The same engineering discipline I'd apply to any production system.

## What it doesn't do yet

I'd rather be honest about the gaps than oversell what's there.

- The third fallback rung (open-weights via OpenRouter) is documented but not yet wired. It needs an OpenAI-compatible client that adds dependencies the harness deliberately avoids until it's actually needed.
- A hybrid retriever (BM25 plus a lightweight reranker) ships as a structural seam. There's a `Retriever` interface and a `hybridRetriever` stub in the code, but the stub currently delegates to BM25. The point of the seam is to let a future eval *measure* whether hybrid retrieval helps on a given domain, rather than assume it does. The OSHA citation-skip rate is the kind of signal that would justify building it out.
- The judge currently flags "partially-faithful" when the agent's facts are right but it dropped a hedging qualifier the source had ("most people pay", "may pay"). A more nuanced judge prompt would distinguish "wrong fact" from "omitted hedge" and weight them differently. Not built yet.
- The eval sets are small on purpose: 11 Medicare pairs and 10 OSHA pairs. The contributing guide explains why: every pair has a provenance tag, and an LLM-generated ground truth would contaminate the eval loop. The sets grow as real domain experts contribute pairs, not as I generate more myself.

These aren't apologies. They're the next things I'd build. Listing them is part of the point.

## Try it yourself, in 30 seconds, with no API key

I wanted anyone to be able to see what fedbench actually does without paying for an Anthropic API key first. The trick: every API-cost surface in the harness is exactly two functions (the agent's call to Claude, and the judge's call to Claude). Everything else (citation matching, refusal scoring, aggregation, the comparison report) is pure code with no network in the loop.

So I added a recording layer. A live run can dump every agent answer and every judge verdict to a JSONL file. A replay run reads that file and routes the same outputs through the same scoring code. The recordings ship with the repo, versioned alongside the questions they correspond to, so a stale recording fails loudly rather than producing wrong numbers.


```bash
git clone https://github.com/midimurphdesigns/fedbench.git
cd fedbench
bun install

# Replay the eval against the recorded agent + judge outputs.
# No API key, no PDF download, no parse step. Pure scoring pipeline.
bun run eval:replay --corpus medicare
bun run eval:replay --corpus osha
```

Each replay finishes in well under a second and prints the same per-pair breakdown, comparison numbers, and pass/fail verdict you'd get from a live run. That's the demo path. If you want to run the harness end-to-end against a real model, the live setup is below.

### Running it for real

You'll need [Bun](https://bun.com), Python 3 with `pypdf` (`pip install pypdf`), and an Anthropic API key.

```bash
# Set your API key
cp .env.example .env
# edit .env and set ANTHROPIC_API_KEY

# Fetch the documents (3 public PDFs, checksum-verified)
bun run corpus:fetch

# Parse the PDFs into per-page text (uses pypdf)
bun run corpus:parse

# Build the chunk index for retrieval
bun run corpus:chunk

# Sanity-check the API connection
bun run smoke

# Run the full eval (11 questions, ~30 seconds, ~$0.30)
bun run eval
```

To run on OSHA instead of Medicare, append `--corpus osha` to each step. Both corpora ship with the repo. Adding your own is documented in the README: manifest, questions file, and a small DomainConfig entry are all you need.

The eval prints a per-question breakdown (citation verdict, judge verdict, cost, latency, which rung answered) and a summary at the end. The full repo, including the architecture and design-notes docs, is at [github.com/midimurphdesigns/fedbench](https://github.com/midimurphdesigns/fedbench).

## How these skills transfer

The harness is a federal-policy demo. The skills underneath it are domain-portable. A few real bottlenecks the same shape addresses:

- **Enterprise RAG that hallucinates in production.** Internal knowledge assistants over policy / contracts / runbooks fail the same way the agent here would without the harness: confidently wrong answers cost real money. A grounded eval set with deterministic citation checks turns "is this answer right?" into a number you can ship against.
- **Customer-support AI citing the wrong policy.** Same shape, different corpus. The deterministic citation check catches "right answer, wrong page" before the LLM-as-judge runs, so disagreements between the two layers are themselves a signal that the support agent's grounding is drifting.
- **Regulated industries (healthcare, financial services, legal) that need an AI accountability trail.** Recordings + per-question provenance (which rung answered, what it cost, what it cited) give a defensible audit trail that satisfies a compliance reviewer in a way "the model said so" never will.
- **Model drift across SDK and model upgrades.** The eval set re-runs in minutes; if Sonnet's next minor version regresses on grounding rigor, the regression shows up in a number, not a customer ticket. This is what continuous evaluation looks like for AI features.
- **Picking models without measurement.** The fallback ladder + cost/latency provenance turn a vibes-based choice ("Sonnet feels better") into a calibrated one ("Haiku is good enough on 90% of intents at 1/5 the cost; here's the audit"). That alone pays for the harness.

These are the conversations I'd rather have than another abstract "agents are the future" exchange.

## Why I built it, and what I'm hoping it starts

fedbench is a small, focused example. The reason it exists is bigger than the example.

A lot of the work I want to be doing more of in the next few years is product-engineering work at AI-applied teams: sitting close to a real team's problem, picking the right model and the right tool for the job, building the eval and the guardrails alongside the feature, and shipping something that holds up under measurement instead of just under a demo. The skills underneath transfer across plenty of domains, not just policy documents. Internal knowledge assistants. Agentic workflows that touch real systems. Cost-and-latency-sensitive AI features inside existing products. Anything where "is this actually good enough" needs to be a number, not an opinion.

fedbench is the first of three companion artifacts that explore that shape from different angles. [fieldops-mcp](/blog/building-fieldops-mcp) is about shaping the tools an agent can use. [grant-pilot](/blog/building-grant-pilot) is about composing tools and sub-agents into a multi-turn workflow that runs end-to-end. fedbench is the rigor underneath both: measurement as a first-class concern instead of an afterthought.

If you're working in any of those areas (at your own company, in your own team, inside Deloitte's AI practice, or at any of the AI-native companies building this kind of system) I'd genuinely enjoy a conversation. The version of that conversation I find most useful is usually the smallest one: one specific problem, one specific constraint, what you've tried, what's surprised you. I'm always open to swapping notes on what's actually working in production right now, and to learning about teams doing interesting work in the space.

Easiest way to reach me is [the contact page](/contact) on this site, or just connect on LinkedIn. The repo is at [github.com/midimurphdesigns/fedbench](https://github.com/midimurphdesigns/fedbench), the live demo is at [fedbench.kevinmurphywebdev.com](https://fedbench.kevinmurphywebdev.com), and the docs in there go deeper than this post does, with an architecture overview, an eval methodology writeup, and a design notes file that covers the calls I haven't gotten to here.

---
## Building fieldops-mcp: designing the tool surface an agent actually uses

URL: https://kevinmurphywebdev.com/blog/building-fieldops-mcp
Date: 2026-05-09
Tags: ai, agents, mcp, tool-design, open-source
Excerpt: I built a small MCP server that turns a fictional dispatcher's workflow into agent tools. The interesting part wasn't the code. It was the design choices that decide what the agent can do at all. Here's what I built and what shipping it taught me.

> **Repo:** [github.com/midimurphdesigns/fieldops-mcp](https://github.com/midimurphdesigns/fieldops-mcp)
>
> **Live showcase:** [fieldops-mcp.kevinmurphywebdev.com](https://fieldops-mcp.kevinmurphywebdev.com)
>
> Companion posts: [fedbench](/blog/building-fedbench) (eval rigor) and [grant-pilot](/blog/building-grant-pilot) (sub-agent orchestration).

I spent a weekend on a second small AI artifact: [fieldops-mcp](https://github.com/midimurphdesigns/fieldops-mcp), an MCP server that exposes a small-business field-services workflow as agent tools. It's a sibling to [fedbench](https://github.com/midimurphdesigns/fedbench): same engineering register, different question. fedbench is about measuring whether an agent is right. fieldops-mcp is about shaping what the agent can do at all.

## What the server does

There's a fictional twelve-person field-services company in `src/fixtures/data.json`: eight techs with overlapping skills, a dozen open jobs, eight customers. None of it real, all of it small enough to hold in your head in thirty seconds.

The server exposes that world as six MCP tools, and the choice of *which six* is most of the actual design work:

- `list_open_jobs`: read the queue, optionally filtered, sorted urgent-first.
- `find_available_techs`: search techs by skill across a time window, ranked by free minutes.
- `assign_job`: book a tech, rejects on skill mismatch or schedule conflict with a typed error.
- `draft_customer_message`: compose a confirmation, delay, or reschedule message grounded in real customer and tech data.
- `compute_utilization`: aggregate per-tech load over a forward window plus a capacity-gap summary.
- `flag_for_human`: escalate ambiguous cases to the dispatcher's review queue rather than guess.

A read, a search, a mutation, a composition, an aggregation, an escalation. Six tools, six structurally distinct shapes.

## Why the shape diversity matters

The mistake I almost made on my first pass was exposing six flavors of `search` (list-by-status, list-by-skill, list-by-customer, list-by-region). The agent doesn't gain new capability from any of those. It gains new ways to do the thing it could already do. That's surface area without leverage.

The shapes I actually picked each unlock something the agent couldn't reasonably do without the tool. Search-and-rank is the join the model would otherwise have to do by hand on a list result, badly. Mutation with typed errors is the affordance the model needs to know its write *failed* (and why) instead of optimistically assuming. Composition reads three records and writes a customer-facing message that quotes the real customer name, the real tech, and the real job description. The agent picks the *intent*; the tool picks the *substance*.

A live Claude transcript captured during the smoke test shows the difference:

```
> find_available_techs({"skill":"hvac","window_start":"2026-05-09T13:00:00Z","window_end":"2026-05-09T16:00:00Z"})
{ "matches": [
    { "tech": "T-01 (Tech A.M.)", "free_minutes_in_window": 180 },
    { "tech": "T-02 (Tech B.K.)", "free_minutes_in_window": 180 },
    { "tech": "T-08 (Tech H.L.)", "free_minutes_in_window": 180 },
    { "tech": "T-05 (Tech E.N.)", "free_minutes_in_window": 120 }
] }

A: I have 4 available HVAC techs. J-2001 requires 120 minutes, and Tech E.N.
   has exactly 120 free minutes while the others have 180. I'll assign Tech A.M.
   to give us more buffer.
```

The model isn't matching strings. It's reasoning over the *shape* of the tool's output, picking the tech with margin instead of the tech that just barely fits. The agent does the judgment; the server does the math.

## What's deliberately not in the server

Two absences worth calling out, because they're as much the design as the presences:

`draft_customer_message` does not send. It returns text. A tool that sends is one the agent cannot undo, and any responsible deployment needs a human-in-the-loop send step. The boundary is intentional.

`flag_for_human` is a tool, not a system-prompt instruction. I could have asked the model to refuse via prompting. Giving it an explicit escalation tool with a structured `reason` field is a more honest design: the refusal is *visible* in the tool-call log, the dispatcher gets a queue item they can act on, and the behavior is gated by something stronger than instruction-following.

## Try it yourself

The repo is MIT-licensed and runs locally. You'll need [Bun](https://bun.com); no API key required for the deterministic mode.

```bash
git clone https://github.com/midimurphdesigns/fieldops-mcp.git
cd fieldops-mcp
bun install

# Deterministic smoke (no API key, ~2 seconds)
bun run smoke
```

That run spawns the MCP server, connects an MCP client to it over stdio, and walks all six tools end-to-end.

To actually *talk* to the server from Claude Desktop, drop this into your MCP config and restart the app:

```json
{
  "mcpServers": {
    "fieldops": {
      "command": "bun",
      "args": ["run", "/absolute/path/to/fieldops-mcp/src/server.ts"]
    }
  }
}
```

Then ask Claude things like *"what urgent jobs are open this morning, and who's the best HVAC tech for J-2001 between 1 and 4 PM today?"* and watch the tool calls happen. The full live transcript is in `docs/TRANSCRIPTS.md`.

## How these skills transfer

Six tools wrapping a fictional dispatcher is the example. The skills are the thing. Real bottlenecks the same shape addresses:

- **Agents making up state instead of mutating it.** Production agent integrations fail catastrophically when the model assumes its tool call succeeded silently. Mutation tools that return typed conflict errors (`{ error: "conflict", message }`) force the model to read a failure and route around it: the difference between an agent that double-books and an agent that asks a human.
- **Agents sending messages they shouldn't.** "Drafts text, doesn't send" is a structural human-in-the-loop checkpoint, not a promise. Customer-facing AI in regulated industries lives or dies by that boundary; the tool surface is where you enforce it.
- **Agents refusing silently or guessing.** An explicit `flag_for_human` tool with a structured `reason` field makes refusal *visible* in the tool-call log and *queue-routable* to a human reviewer. That's a feature regulated buyers actually pay for; it's also a way to gather training data for the next iteration.
- **Enterprise integrations with 50 thin wrappers.** Most "AI on top of our API" projects fail because they expose 50 endpoints to the model and trust the model to pick. Six well-shaped tools (read / search-and-rank / mutate / compose / aggregate / escalate) beat 50 thin wrappers because the model doesn't have to reason about your schema. It can reason about the workflow.
- **AI-native products that need predictable agent behavior.** MCP as a contract means the agent's capability is decided in your code, not the prompt. That's the difference between a feature you can ship and a feature you can demo.

This is the layer between "the agent" and "the system," and it's where most of the applied-AI product work in 2026 actually happens.

## Why I built this one

A lot of the work I want to be doing more of in the next few years sits at the boundary between a real team's workflow and the agent that helps them run it. The skill I find most interesting in 2026 is designing the tool surface itself. Picking which tools an agent gets, what they return, how their errors are shaped, where the human stays in the loop. Most of the agent capability you experience as a user is decided in those choices, before the model is even prompted.

fieldops-mcp is the second of three companion artifacts that explore that shape from different angles. [fedbench](/blog/building-fedbench) gave me a way to talk about evaluation rigor. fieldops-mcp gives me a way to talk about tool-surface design. [grant-pilot](/blog/building-grant-pilot) ties both together: a multi-turn agent that composes specialist sub-agents over real public data, with budget caps, rate limits, and structured-failure routing. They're siblings in my head and they're cross-linked in each other's READMEs.

If you're working on agent-shaped problems (your own product, your own team, inside Deloitte's AI practice, or at any of the AI-native companies building this kind of system) I'd genuinely enjoy a conversation. The version I find most useful is usually the smallest: one specific workflow, what tools you ended up exposing, what you almost shipped and pulled back. I'd rather swap notes on what's actually working than trade abstractions about agents in general.

Easiest way to reach me is [the contact page](/contact) on this site, or just connect on LinkedIn. The repo is at [github.com/midimurphdesigns/fieldops-mcp](https://github.com/midimurphdesigns/fieldops-mcp), the live showcase is at [fieldops-mcp.kevinmurphywebdev.com](https://fieldops-mcp.kevinmurphywebdev.com), and the docs in there go deeper than this post: an architecture overview, a tool-design heuristics file, fixture conventions, and a transcript-capture protocol so the example runs in the README stay aligned with real server behavior.

---
## Frontend is a discipline, not a stylesheet

URL: https://kevinmurphywebdev.com/blog/frontend-is-a-discipline
Date: 2026-05-07
Tags: frontend, craft, engineering-culture
Excerpt: The engineers who declare frontend trivial are usually the same ones rerouting every CSS ticket. The mismatch is worth examining, and what 'just CSS' actually costs to ship is broader than the meme suggests.

There's a recurring genre on engineering social media: a backend engineer or a systems person declaring that frontend work is trivial. Just HTML and CSS. A border, a button, a margin. The implication is that anyone with a real engineering mind would obviously be doing the harder thing on the other side of the API.

I've spent ten years in fullstack roles, and I've watched this take repeat enough times to notice the pattern around it. The same engineers who declare frontend easy are the ones who reroute every CSS ticket. The same engineers who insist "the frontend just renders what the backend gives it" are the ones who can't get a navbar to render correctly when they try. The mismatch is worth examining, not because it makes anyone wrong, but because it's data about what the work actually requires.

So what does the work actually require? Most of it isn't visible until you've shipped product into production for real users on real devices. A short list of the surfaces I've watched eat a senior backend engineer alive when they try to pick them up casually:

- **Accessibility.** WCAG conformance isn't a styling concern. It's a discipline of semantic HTML, focus management, screen-reader testing, keyboard navigation, and color contrast, all of it verified against assistive technology that you actually have to run. I led frontend accessibility on Michigan's React modernization of unemployment insurance for 270,000 residents across 2,000+ pages. The conformance bar was set by federal law, not preference, and the testing involved caseworkers running screen readers on five-year-old machines. The "just add aria-label" instinct doesn't survive a real audit.
- **Performance under real-world conditions.** A modern frontend operates against measured budgets (LCP, INP, CLS) governed by what a user on a flaky connection and a five-year-old phone can actually load. Hitting them requires bundle analysis, code-splitting strategy, font-loading discipline, image-pipeline decisions, and a working understanding of where the rendering critical path lives. None of which is CSS.
- **State coordination at scale.** A modern React app coordinates server state, client state, optimistic updates, derived state, and form state, often across views, often with race conditions that are subtle until they aren't. Reasoning about cache invalidation in TanStack Query is the same cognitive shape as reasoning about cache invalidation in a backend service. The "frontend just renders" framing collapses the moment you have to explain why three components are stale at slightly different times.
- **The cross-device matrix.** Frontend ships into a multiplicative test surface: iOS Safari quirks, Android browser variants, hybrid devices that report a fine pointer but use touch, screen readers with their own rendering rules, RTL scripts, locale-specific typography. The closest analogue in backend work is multi-region failover, and that's a different shape of complexity entirely.
- **Motion and interaction.** Done well, motion *is* the layout. How the product communicates state, hierarchy, and consequence. Done badly, it kills perceived performance and induces nausea. The discipline is part visual judgment, part physics, part frame-budget engineering, part empathy for the human nervous system. None of it is CSS in the meme sense.
- **The build and tooling stack.** Modern frontend toolchains are themselves a systems-engineering surface: bundlers, transformers, source maps, type-checking pipelines, monorepo coordination, design-token systems, MDX pipelines, edge-runtime deployment. The toolchain alone is more complex than most backend deployment pipelines I've worked in.

That's six items, picked because each one is large enough to absorb a senior career on its own. The work is real, and it doesn't compress into a stylesheet.

The pattern shows up most clearly when teams try to redistribute frontend work to engineers who don't have the instinct. In 2021 I was staffed onto FedNow at the Federal Reserve, a Salesforce Lightning Web Components build with hard governance constraints, including WCAG accessibility conformance for a regulated payments interface. My initial access was scoped to CSS only: no HTML, no JavaScript, no LWC component layer. The team was new to the contractor relationship and the regulated environment didn't yet trust the broader surface area.

The first MVP component the team needed was a navbar. The senior backend engineer assigned to it spent weeks fighting it and couldn't ship. I asked if I could try, the team agreed, and I built it from scratch to a production-ready state in a few days. The work was foundational, not cosmetic. The navbar and its related functionality were the first real frontend in the codebase, which meant the architecture and design patterns I established on it would shape the entire frontend system that came after. Those decisions had to be made right; they shaped the delivery of the rest of the project.

Access restrictions came off immediately after I shipped the navbar. From there I built out the frontend system end-to-end: a reusable component library, advanced data tables with custom functionality, and a custom form wizard that was reusable across the many forms the app required. I carved out a niche that was vital across the software-development lifecycle of that project and helped ship millions of dollars' worth of work on an instant-payment rail that would go on to move trillions in settlement flows. The client asked for me by name on every contract extension; my colleagues had taken to calling me Frontend Jesus. The project wouldn't have shipped without that frontend delivery.

The lesson I took from it isn't that the backend engineer was bad. They were excellent at the work they were excellent at. The lesson is that frontend instinct is a separate discipline, and the work was sitting there waiting for someone who had it. When the discipline is treated as decorative, the work doesn't get done. When it's recognized as a specialization, the right person picks it up and the product ships.

I'm a fullstack engineer by training. I've shipped backend services, database integrations, automated pipelines, and the cloud infrastructure underneath all of it. The reason I land on frontend on most projects isn't that backend work isn't interesting; it's that on most teams I've shipped with at scale, the frontend was the under-resourced surface. The work was sitting there, and senior engineers who can hold a user-facing product to a serious bar are scarcer than they should be. There's outsized leverage in being the engineer who closes that gap, and that's been true on every project I've shipped with weight: Michigan unemployment, the IRS modernization, FedNow, the ASU mobile app.

The companies that treat frontend as a competitive advantage are the ones whose products feel like products. Stripe didn't reach the position it has on backend uniqueness; the API is excellent, but so is everyone else's. The advantage is the developer experience: the documentation, the dashboard, the response shape, the error messages, the time-to-first-success. Frontend at a depth most companies don't invest in. Linear trades on the same axis. So does Vercel. So does Notion. The companies that treat the surface as a formality ship surfaces that feel like formalities.

Frontend is a discipline. The work has weight. The engineers who do it well know systems, performance, accessibility, motion, state, and the human end of the stack as carefully as any backend engineer knows distributed transactions. Don't underestimate your frontend talent. The companies that recognize the depth early get the leverage; the ones that don't ship products that look like they didn't.

---
## What five years from bootcamp to senior taught me

URL: https://kevinmurphywebdev.com/blog/bootcamp-to-senior-7-sentences
Date: 2026-04-25
Tags: bootcamp, mentorship
Excerpt: Seven patterns I noticed somewhere between bootcamp in 2018 and senior at Deloitte around 2023, written for the version of me at year two.

I started my coding bootcamp in 2018. I made senior at Deloitte around 2023. Five years, give or take, from "what's a closure" to running real engineering reviews.

Most of the things I'd actually want to say to the version of me at year two aren't the things people told me. They told me to communicate clearly and write good tests, and I nodded, and I went back to writing the code, because the code was the part I knew how to control.

In retrospect, here are seven patterns that turned out to matter more than I gave them credit for at the time.

1. **Code stops being the constraint somewhere around year three.** By then you've internalized your stack. You can ship a feature without needing the docs open. The constraint shifts to the things wrapping around the code: whether you asked the right questions before you started, whether you wrote the doc the next person on the team needs, whether you pushed back on a spec that didn't make sense.
2. **"Senior" is a verb more than a title.** It's a thing you do for people around you. The first time you watch a junior write something in a way they wouldn't have written without your standard influencing them, you start to understand what the title is for. Until then it's mostly just compensation.
3. **Most of the bug reports you'll get aren't bugs.** They're communication failures somewhere upstream of the code. Investing in understanding what the person is actually asking for, before writing the patch, has saved me more time than any tool I've adopted.
4. **Performance budgets are mostly a stakeholder problem.** Anyone can make a page faster. The harder part is committing to a number, defending it across a team, and making it stick when product or design have different priorities for the same sprint.
5. **You'll meet engineers smarter than you who can't ship.** I used to find that depressing. Now I find it useful. It tells me that "smarter" was never really the variable. Shipping discipline is a skill, and it's learnable, and a lot of people who are great at the first part never get around to learning the second.
6. **You learn your own reasoning by explaining it to juniors.** There are habits I'd been running on for years that I couldn't actually articulate until somebody on a smaller team asked me why I did the thing that way. Teaching makes the implicit explicit. It also forces you to figure out which of your habits are real principles and which are just reflex.
7. **The people hiring seniors aren't checking off frameworks.** They're looking for someone who has stopped breaking things in particular ways. The interview answer to "tell me about a time you debugged something hard" matters more than the part of the screen where they ask about TypeScript.

None of these are novel. The only thing I'd add is that nobody told me any of them at year two in a way that landed, and the ones that eventually did land all came from people who'd been wrong about something the same way I was about to be wrong about it.

The shift to senior, looking back, is mostly the moment you stop trying to control the part you already know.

---
## Why I'm not chasing FAANG (and where I think frontend talent is actually moving)

URL: https://kevinmurphywebdev.com/blog/not-chasing-faang-where-talent-moves
Date: 2026-04-19
Tags: big-tech, startup
Excerpt: The default career advice for senior engineers in 2026 is wrong for at least half the people receiving it, and the reasons it's wrong have shifted hard in the last two years.

The default career advice for senior engineers in 2026 still runs roughly: stack credibility, get to FAANG, ride the comp curve. It's worked for two decades. I'm not arguing against it for the people it works for.

For me, the trade-offs land differently.

What I want is a senior role at an SMB or mid-stage product company, hybrid or remote, in a place where my quality-of-life index holds up against whatever the salary buys. The rest of this post is the reasoning behind that preference and where I think senior frontend talent is moving in 2026.

Part of that is logistical. I've spent years building a life in Tempe (a friend group I rely on, a gym I show up at, a home I've turned into the work-from-home setup that actually works for me, and a cost of living that's closer to fair than what's available in any tech metro). I have no family in Arizona; I'm here for the people and the daily rhythm I've built around them. Tempe is the baseline I'd be moving from. I know most of the strongest tech companies are in the Bay Area or New York, and I'd seriously consider either for the right role: work I'm passionate about, a team I can build real connection with. The geography isn't the gate; the role and the team are. So when I look at FAANG specifically, the question is the rest of this post.

A note on work format, since it tends to come up. I've spent years as a remote-first engineer at Deloitte, paired daily with offshore teammates in India and onshore colleagues across the country, and the practicality has been clear: deep-focus time, no commute, control over the environment I do my best work in. Remote is my preference for the trade-offs it lets me make, but I'm flexible. Remote, hybrid, and in-person all have versions that work for the right role, and plenty of strong companies (FAANG included) hire remote talent from out of state. The format isn't what's driving the rest of this post.

What is driving it is what I saw inside the actual interview loops. I've interviewed in this space (a FAANG, a FAANG-adjacent fintech, and a Bay Area AI company that fit the closer-to-what-I-actually-want shape). The more time I spent inside those loops and reading the work, the more I landed on a thesis that goes well beyond where I happen to live.

Here's what I actually think:

- **The companies built to win the next decade are smaller and more personal than they used to be.** What I keep coming back to is that I want to work somewhere the founding team still talks to customers and the engineers have a direct line to the people the work is for. Small-to-medium businesses (SMBs) and mid-stage companies still have that. FAANG, structurally, doesn't.
- **Smaller companies pivot faster, and pivoting is the game right now.** A thirty-person company can absorb a new tool, change its pricing model, or rewrite a workflow inside a quarter. A FAANG can't. The bureaucracy that protects a big company from blowing itself up also stops it from following AI-shaped market shifts as they happen. In a year where the market moves every six weeks, that gap matters more than it did in 2020, and the SMBs that move on a market trend first are the ones eating durable share.
- **AI tooling has flattened the company-size advantage on quality.** Until pretty recently, the case for a big company was that you got to work on big problems with infrastructure you couldn't build alone. With Claude Code, agentic CLIs, and the rest of the 2026 toolkit, a small team can ship at a quality bar that took a fifty-person org five years ago. The marginal value of "I can move the whole stack myself" is going up, not down, and that skill is built faster outside FAANG than inside it.
- **The higher comp at FAANG buys less the more you factor in.** A senior FAANG offer reads big on the page. Set it against the cost of living in a major tech metro, the pace and intensity of life there, and the constant on-call pressure that rides with the role, and each extra dollar of pay does less and less work for you. The compensation is real and I'm not pretending it isn't, but the curve flattens fast. At some point a senior role at an SMB in a lower-cost market becomes a fully valid preference to choose over a FAANG package, and for plenty of us, the one we'd pick.
- **The way the big-tech layoffs of the last few years were executed has been hard to unsee.** Every company has layoffs; that's not the issue. The issue is the form. Engineers who shipped real work, gone in a Slack message. Whatever else FAANG is selling, "we look out for our people" stopped being a credible part of the pitch somewhere around 2022. When the calculus tightens, the comp number is the only thing the company has left to offer, and the comp number alone isn't enough.
- **The LeetCode-and-certifications filter is overlooking quality senior talent.** LeetCode tests two things at once: pattern recall, and the ability to talk through reasoning under pressure. The second is a real skill, but LeetCode isn't where it's most useful. The communication that actually compounds at the senior level is the kind that makes hard-to-visualize work legible: data flows, system architectures, the *why* behind a trade-off. That's a different muscle than walking through a graph traversal. Certifications earned ahead of need carry the same shape: they signal preparation for problems you may or may not face, not capacity for the ones in front of you.

The information surface is too broad to memorize end-to-end; trying produces shallow recall on a thousand topics instead of deep judgment on the ones that matter. I'd rather hold an *always-a-student* posture and treat each problem as deserving its own analysis than carry a pre-built one-size-fits-all template into work that increasingly doesn't have one. Agility in critical thinking (sizing up an unfamiliar problem, knowing when to reach for what, learning what's missing in the moment) has been a more durable senior skill, in my experience, than rigid recall. The same logic carries to credentials: I've earned six certifications across AWS and Salesforce over the years, two AWS still active. I don't regret any of them, but a single real production incident has taught me more about the actual systems than the entire study cycle for any one of them. Study what the work in front of you is asking for. The rest comes when the work asks for it.

All of this comes down to a single trade, and only the person being recruited can weigh it: a higher salary paired with a lower quality-of-life index, against a smaller salary paired with a higher one. Hours are one piece of the calculation (a forty-hour week vs. a fifty-to-sixty-plus-hour week with on-call rotations stacked on top) but only one piece. The full scale weighs everything that shapes the life around the work. The variables I weigh:

- **Work-life balance.** Protected hours vs. a pager that can fire on weekends.
- **The pace of the place you live.** A calm, less-hectic city vs. a fast, crowded one.
- **Personable connection.** How much depth you can build into the relationships around your week.
- **Salary against cost of living.** A smaller number in a reasonable metro often spends further than a bigger one in the Bay.
- **Overall wellness and quality-of-life index.** The lived experience that surrounds the work itself.

Where does the balance land for you? Some people genuinely want to maximize compensation above all else, and that's a coherent choice. They should take it. For me, when I weight the whole picture, lifestyle and stability outweigh a marginally higher salary by a wide margin. Every situation is unique and deserves its own honest accounting of values; what I can speak to is my own lean. Mine is heavy toward the kind of life I want to be living, with good and stable work inside it, over a comp number that doesn't move the needle on the week I actually live.

So where do I actually think frontend talent is concentrating in 2026?

- **Mid-stage product companies where someone in the founding team still has taste.** Roughly $50M–$500M in revenue. Small enough that a good frontend engineer moves the needle, large enough that the work is real. Companies in the Linear / Vercel / Notion / Ramp shape, where the surface is what's actually being sold, and a senior who can hold both the system side and the user side has outsized leverage. The *design engineer* track has matured into a real senior path at this tier of company over the last few years; it's where frontend craft compounds the fastest, because craft is a competitive moat for the company, not a delivery layer underneath one.
- **Vertical SaaS with a real moat: regulated workflows, proprietary data, or distribution.** Healthcare, legal, government services, financial compliance. Toast in restaurants, Veeva in life sciences, nCino in commercial banking, Procore in construction. Companies that built decade-long businesses on domain depth and the cost of switching, not on a clever algorithm. AI raises the floor for everyone but doesn't easily cross workflows where the data is locked behind regulation or where the integration effort with incumbent systems is the actual product. Frontend at this tier has its own character: the workflows are dense, the user is a domain expert, and a senior who can make a complex regulated UI legible without dumbing it down is genuinely scarce. Seniors get paid here because the bar is real.
- **Established SMBs outside the major tech metros.** Product companies in cities like Austin, Boulder, Salt Lake, Phoenix, Pittsburgh, Indianapolis. Places that ship real software to real customers, run lean, don't try to imitate FAANG culture, and reward seniors who can hold the whole picture. Frontend here often means owning the surface end-to-end; you're not slotting into a sixty-person design-system org, you're building the design system that the next sixty engineers will use. Quietly, some of the most stable senior careers I know have landed here.

Where I'd be cautious in 2026:

- **Ad-tech and crypto** continue to absorb engineering talent and produce outcomes that are middling on the median. For frontend specifically, the day-to-day tends to be variance on dashboards, charts, and configuration UIs. Real engineering, but it doesn't compound into the kind of portfolio that opens doors at design-led companies later. Cyclical industries also produce cyclical layoffs; frontend orgs are often first to contract when revenue tightens.
- **Single-feature SaaS with no real moat.** Companies built on a paywall around generally-available knowledge are watching that moat erode in real time as AI does the work directly. Chegg is the cleanest case: about half its subscriber base lost to ChatGPT, the stock down close to ninety-nine percent from its 2021 peak. When the moat goes, the frontend is usually what gets minimized first; leadership cuts the most visible expense, and senior craft work doesn't survive a contraction at this tier. The survivors of this category will be the ones with proprietary data, regulated lock-in, or distribution AI alone can't replicate.
- **Pure model companies** (whose only product is "we trained a model") are consolidating around a small number of winners. The economics of frontier-model training are pushing the second-tier ones into a hard couple of years. For frontend, the structural concern compounds: at most pure model shops, the UI is treated as a support layer for the model rather than as a product surface in its own right. Senior frontend who want to drive product judgment land in constant tension with research-led and PM-led roadmaps. The craft doesn't get the room it needs to compound.

I'm not trying to talk anyone out of FAANG. The path is real, I have friends who are happy on it, and there are senior engineers who legitimately want what FAANG is selling. What I'm saying is that "FAANG is the obvious move" stopped being accurate somewhere between 2023 and now, and the alternative paths have improved enough that the second-best option in 2018 reads like the equal (and in some cases the better) option in 2026.

If you're a senior engineer wondering what's next, there are more good answers now than there used to be. That's good news.

---
## An anthropology degree was the most useful thing I brought to engineering

URL: https://kevinmurphywebdev.com/blog/anthropology-degree-engineering
Date: 2026-04-15
Tags: identity, humanities
Excerpt: The major engineers laugh at quietly turned out to be the edge, and the branch of anthropology I picked is the one with the most well-trodden path into tech.

When I tell people I have an anthropology degree, the response is usually a small wince followed by some version of "huh, that's interesting, how'd you end up in tech." Polite. Slightly puzzled. Sometimes condescending if the person is younger than me.

I used to be defensive about it. Now I think it was the most directly useful thing I did in college.

The branch I studied was *cultural anthropology*, one of the four classical branches of the discipline. The other three are biological, archaeological, and linguistic anthropology; they're great fields, but they don't bridge into a software career nearly as cleanly. Cultural anthropology is the branch focused on living people, social systems, and how groups actually make meaning out of the things around them. It's the branch that gave rise to **applied anthropology**: the formal discipline of taking ethnographic methods out of academia and into business, design, and technology.

That bridge isn't theoretical. UX research at most of the major tech companies has anthropologists in its lineage. Intel famously built a research org in the 2000s around cultural anthropologists like Genevieve Bell; Microsoft and Xerox PARC ran similar programs. The methods those teams imported (participant observation, contextual inquiry, interpretive interviewing) are still central to modern product research. The branch I happened to pick is the one with the most well-trodden path into tech, and I didn't know that when I declared the major. I figured it out in the field, literally.

The handoff from anthropology to web development happened at the [Smithsonian Folkways digital archive](/portfolio/smithsonian-folkways), an internship at the Smithsonian Institution's Center for Folklife and Cultural Heritage in Washington, D.C., the summer before I knew I wanted to be an engineer. I came in with a BA and no career direction. I left with a personal website, working knowledge of WordPress, and the understanding that the part of the job I cared about (watching how real people interact with the thing in front of them) translated cleanly out of a cultural archive and into shipping software. That internship is the straightest line in my professional history.

Here's the unflattering truth about both fields: nobody learns to be a senior engineer in school anyway. The CS majors I work with had to learn the actual job after they got hired, same as me. What school gives you is a set of habits of thought that you'll apply to whatever you do next.

The habits cultural anthropology gave me, and the ones that have shown up the most in engineering:

- **Take fieldwork seriously.** When you walk into a new system (a codebase, a team, an enterprise client's organization) you are doing fieldwork. The thing that is true is what people actually do, not what they claim they do, and not what the documentation says. Senior engineers who skip this step get expensive surprises. Anthropologists are trained to default to fieldwork.
- **Watch for ritual that nobody can explain.** Every team has rituals. Some of them have reasons that still hold. Some of them are residue from a constraint that disappeared three years ago. The trick isn't to throw all of them out; the trick is to ask gently and see which ones get a real answer. The ones that don't are usually safe to retire. (FedNow had a fourteen-day review step that turned out to be fossilized residue from an audit requirement that had been resolved years earlier. Killing it saved us a third of the schedule.)
- **Resist your own model.** Engineers are trained to abstract. We see a problem and immediately reach for a pattern we already understand. Anthropologists are trained, painfully, to do the opposite: to hold off on naming what you're seeing until the data has had a chance to surprise you. About half the production bugs I've shipped were because I assumed I knew the shape of the problem before checking.
- **Note the gap between what's said and what's done.** This one is gold for working with stakeholders. People will tell you what they want. They will then behave in ways that contradict what they told you. The contradiction is the actual signal. If you can see it without judgment, you're already in the top quartile of cross-functional collaborators.
- **Build for the actual user, not the PRD.** The PRD and the acceptance criteria describe the artifact a team agreed to build. They don't describe how a person will actually try to use it: which keystrokes they'll get wrong, which step they'll skip, which empty state they'll stare at for thirty seconds and then close the tab on. If you only ship to the spec, you're shipping to a fictional user. The fine texture of how the real one moves through the product is where the difference between a passable feature and a genuinely good one lives, and that texture is exactly what cultural anthropology trains you to see.

I'm not saying every engineer needs an anthropology degree. What I will say is that holding the analytical, systems-shaped half of engineering and the observational, interpretive half from those anthropology classes in the same head (left-brain and right-brain wired into the same role) is what I think makes my work feel distinct. I tend to notice things on the human side of a project that a purely technical lens doesn't reach for: the small misuses, the workflow seams, the user behavior that quietly contradicts the spec. I bring those notices back into the code. That combination, more than anything else on my résumé, is the part that actually shows up in the quality of what I ship.

Engineering schools could stand to teach more of this. They mostly don't, because nobody's writing LeetCode problems for *is this stakeholder telling you the real constraint*. The good engineers learn it on the job and the great ones bring it in from somewhere else.

I happened to bring it in from anthropology. Other people bring it from theater, from the military, from years as a parent. The substance is the same. It's the part of the job that isn't code.

---
## loom — durable AI commerce with Vercel Workflows and agentic spending guardrails

URL: https://kevinmurphywebdev.com/portfolio/loom
Role: Author · Applied AI
Year: 2026
Stack: TypeScript, Next.js 16, Vercel Workflow SDK, Vercel AI SDK, Anthropic SDK, Stripe, Zod, Upstash
Summary: Public open-source durable AI-commerce backend. Four workflows demonstrate the patterns that make agent-driven money movement safe in production: cart abandonment with durable sleep and idempotent email, dynamic checkout with bounded discount negotiation, shipping monitoring with saga compensation, and a Stripe webhook drift demo showing why webhook stores are durable logs not queues. Agentic spending authority bounded by a deterministic gate that runs after the LLM finishes. A Sirens eval harness runs ten adversarial scenarios in CI and asserts the gate holds. A failure-injection harness throws between step execution and step recording and proves zero duplicate side effects across N trials. Cost-aware model routing (Haiku for generators, Opus for structured decisions) with a daily USD cap and budget-aware short-circuit before every LLM call.

> **Repo:** [github.com/midimurphdesigns/loom](https://github.com/midimurphdesigns/loom)
>
> **Live demo:** [loom.kevinmurphywebdev.com](https://loom.kevinmurphywebdev.com)
>
> **Read the full story:** [Building loom](/blog/building-loom)

Loom is a durable AI-commerce backend. Four workflows demonstrate the patterns that make agent-driven money movement safe in production: cart abandonment with durable sleep and idempotent email, dynamic checkout with bounded discount negotiation, shipping monitoring with saga compensation, and a Stripe webhook drift demo that shows why webhook stores are durable logs and not queues. <Stat>4</Stat> workflows, <Stat>10</Stat> adversarial Sirens scenarios, <Stat>$2/day</Stat> default cost cap with budget-aware short-circuit before every LLM call.

## How it's built

Next.js 16 App Router on Vercel, TypeScript strict, zero `any`. The Vercel Workflow SDK (GA) provides durable sleep and per-step checkpointing. The Vercel AI SDK on `@ai-sdk/anthropic` handles every LLM call, with `generateObject` plus Zod schemas for structured agent decisions and `generateText` for free-form drafts. Upstash provides the budget counter, the per-visitor event lists, the consumer cursors, and the rate-limit window.

The cart-abandonment workflow runs a durable sleep (six hours in production, compressed via `LOOM_DEMO_SLEEP_MS` for the demo), drafts a re-engagement email with Haiku, and sends it through an idempotent receiver. The send-email step's idempotency key is the composite `workflowId:stepName`. The mock email provider in `lib/email.ts` persists the key in Upstash with a TTL; second sends return `deduplicated: true` and the underlying email API never fires twice. That is the at-least-once + idempotent-receiver + stable-key chain in one workflow.

The dynamic-checkout workflow runs an Opus-backed `negotiate_discount` step against four attack presets. The model returns a structured `AgentDecision` via `generateObject` and a Zod discriminated union; its entire output surface is one of `{ action: 'discount' | 'refund' | 'no_action', amountCents, reason }`. The next step calls `authorizeDiscount` from `lib/agent-authority.ts`, which compares `amountCents` against `MAX_DISCOUNT_USD * 100` read from the environment. The ceiling never appears in any prompt. The LLM never has a path to write past it.

The shipping-monitor workflow demonstrates saga compensation. Book carrier A, attempt carrier B, catch `CarrierFailureError` from B, run the paired compensation step that cancels carrier A. The booking idempotency key lives in the `loom:carrier:booking:` namespace; the cancel key lives in `loom:carrier:cancel:`. Different namespaces deliberately, so the cancel call does not dedupe-return the booking record.

The Stripe webhook drift demo persists every verified event to `loom:stripe:event:<id>` with a thirty-day TTL and appends to a per-visitor list. Consumption is independent: a separate endpoint walks the list newest-first, picks the first event not in the visitor's consumer-cursor set, and advances the cursor. The event itself stays in the store. The locked phrasing: webhook stores are durable logs, not queues. Multiple consumers, individual cursors, TTL-based eviction.

## Agentic spending authority and Sirens

The runtime gate is five lines of plain code: read the ceiling from the environment, compare to the requested amount, return `decision_blocked` if it exceeds. That is the safety mechanism. Sirens is the evidence.

`scripts/sirens.ts` runs ten adversarial scenarios offline against the same path the runtime uses (prompt to LLM to gate to outcome). Vague pressure, fabricated authority, system-prompt-leak attempts, JSON injection, ceiling-math tricks, chained-reasoning attacks. After each scenario completes, Sirens asserts the applied amount stays at or below `MAX_DISCOUNT_USD`. Snapshot writes to `.loom/sirens/<timestamp>.json` for diffing across prompt changes and model upgrades. The assertion never fires because the deterministic gate always catches the overshoot. Unit tests prove the gate code is correct against known inputs. Sirens proves the gate holds against adversarial inputs the model did not see during training.

## Failure injection

`scripts/failure-injection.ts` wraps a `DurableStubProvider` with a `KillingProvider` that throws after `await fn()` returns but before the step result is persisted. That is the worst case for durability: the side effect happened, but the workflow has no memory it happened. On replay, the workflow asks for that step's result, the provider has nothing, the workflow re-runs `fn()`. The receiver-side idempotency key is what makes that re-run safe. The harness runs N=5 trials per workflow per phase and asserts recovery completed, the email audit log shows exactly one entry per run, and zero sends were dropped. Not durable in theory; measurable durability under fault injection.

## Artifacts worth reading

- [`docs/ARCHITECTURE.md`](https://github.com/midimurphdesigns/loom/blob/main/docs/ARCHITECTURE.md). The design contract: orchestration abstraction, exactly-once chain, saga shape, webhook drift handling, agent spending authority, failure-injection methodology, cost discipline, intentional non-goals.
- [`lib/agent-authority.ts`](https://github.com/midimurphdesigns/loom/blob/main/lib/agent-authority.ts). The five-line gate. The deterministic code that runs after the LLM finishes.
- [`lib/workflows/`](https://github.com/midimurphdesigns/loom/tree/main/lib/workflows). The four workflow definitions. Each one composes Vercel Workflow SDK primitives (durable sleep, per-step checkpointing) with the receiver-side idempotency contracts in `lib/email.ts`, `lib/carrier.ts`, and the agent-authority gate.
- [`scripts/sirens.ts`](https://github.com/midimurphdesigns/loom/blob/main/scripts/sirens.ts). The adversarial eval harness. Ten scenarios that prove the gate holds.

## The trade-offs

The carrier API, the email provider, and the Stripe checkout flow are all fixture-backed; the real adapters are a separate phase. The transactional-outbox dispatcher that closes the gap between webhook receive and workflow start is documented in the architecture doc as a Phase 7 deferred item rather than pretending it exists. Loom's cost cap is global to the demo; production needs per-team ceilings. OpenTelemetry tracing is not wired; production has to add spans for every workflow, step, and adapter call so a stuck workflow is debuggable by an SRE who has never read the source. The architecture is shaped for these additions; the demo intentionally stops before them.

---
## forge — multi-agent debugging concierge

URL: https://kevinmurphywebdev.com/portfolio/forge
Role: Author · Applied AI
Year: 2026
Stack: TypeScript, Next.js 16, Vercel AI SDK, Anthropic SDK, p-limit, Zod, Upstash, Tailwind v4
Summary: Point it at a stack trace and four specialist subagents fan out in parallel, each with a focused tool set, before a coordinator merges their structured findings into ranked, calibration-weighted hypotheses. Parallel fan-out via Promise.all plus pLimit bounded concurrency. Two-pass agent pattern (generateText then generateObject) for reliable structured output from a tool-using loop. Durable session state with resumable streams. Preemptive abort plumbed end-to-end via AbortSignal, with cross-instance signaling through Upstash so the stop button works across Vercel serverless replicas. Brier-score calibration log as a system-level feedback loop. Five-scenario eval harness with graded rubric and n-runs aggregation. Anthropic prompt-caching breakpoints with honest below-threshold disclosure. Hardened with per-IP rate limit and daily USD spend cap.

> **Repo:** [github.com/midimurphdesigns/forge](https://github.com/midimurphdesigns/forge)
>
> **Live demo:** [forge.kevinmurphywebdev.com](https://forge.kevinmurphywebdev.com)
>
> **Read the full story:** [Building forge](/blog/building-forge)

Forge is a multi-agent debugging concierge. Paste a stack trace; four specialist subagents fan out in parallel, each with its own focused tool set; a coordinator merges their structured findings into ranked, calibration-weighted hypotheses; and the whole investigation streams to the browser as it unfolds. <Stat>4</Stat> parallel subagents, <Stat>~$0.08</Stat> per full investigation on Claude Sonnet 4.6, <Stat>~1s</Stat> click-to-aborted latency on Vercel.

## How it's built

Next.js 16 App Router on Vercel, TypeScript strict, zero `any`. The Vercel AI SDK (`generateText` plus `generateObject`) on `@ai-sdk/anthropic` with Claude Sonnet 4.6. Parallel lane dispatch via `Promise.all` over `pLimit(4)`, with each lane catching its own errors and returning a typed `LaneOutcome` discriminated union so the outer promise never rejects. Two-pass agent pattern per lane: `generateText` with tools and `stepCountIs(N)` for the investigation loop, then `generateObject` with a Zod schema to coerce the transcript into a typed result. The AI SDK enforces this split because mixing tool use and structured output in one call hallucinates one or the other.

Session state is durable. Each investigation gets a UUID, the browser pins it to the URL via `history.replaceState`, and refresh fires a resume GET that replays the buffered lane state and the merged hypotheses. The same UUID can be shared across browsers and they all subscribe to the same snapshot. The session store is interface-shaped so swapping the dev in-memory implementation for Upstash KV is a one-file change.

Per-lane interrupt is preemptive, not cooperative. The click handler optimistically sets the lane status to `stopping`, writes the abort intent to an Upstash Set (the cross-instance signal), and the coordinator's 500ms poll loop reads the Set and calls `controller.abort()` on the lane's local AbortController. The signal propagates through `generateText`'s `abortSignal` parameter all the way down to the fetch to api.anthropic.com, which closes its socket. Click-to-aborted round-trip is roughly one second on Vercel, instant on localhost.

## Calibration as system-level learning

Every (predicted confidence, rubric outcome) pair gets logged to Upstash after each session. Brier scores are computed per lane (mean squared error between predicted probability and binary outcome). Weights derive from mean outcome divided by mean predicted, clamped to the range 0.5 to 1.5 with a three-sample floor before weighting activates. The coordinator multiplies each lane's confidence by its weight before ranking the merged hypotheses. The lanes themselves are stateless function calls; the system's memory lives in the calibration log. A chronically overconfident lane gets downweighted; an underconfident lane gets upweighted. The system gets better at surfacing correct answers even though no individual lane improves.

## Eval discipline

Forge ships with a CLI eval runner ([scripts/eval.ts](https://github.com/midimurphdesigns/forge/blob/main/scripts/eval.ts)) that runs five reproducible bug scenarios N times each against a graded rubric. The rubric scores correctness components (file match, line-range intersection-over-union, top suspect match, severity exact) plus process components (snippet present, reasoning length, candidate explanations). N-runs aggregation reports mean and standard deviation per scenario so a prompt change has to clear statistical significance to count as an improvement, not single-run noise. The Brier outcome that feeds calibration is derived from total rubric score with a 60% threshold so partial-correct answers (right file, slightly wrong line range) count as the useful signal they are.

## Artifacts worth reading

- [`lib/coordinator.ts`](https://github.com/midimurphdesigns/forge/blob/main/lib/coordinator.ts). The fan-out, the per-lane AbortController, the cross-instance Upstash poll, the calibration-aware merge. The center of the system.
- [`docs/AGENTS.md`](https://github.com/midimurphdesigns/forge/blob/main/docs/AGENTS.md). The design contract for the four subagents, written before the code so it could be re-explained from words later.
- [`lib/eval/rubric.ts`](https://github.com/midimurphdesigns/forge/blob/main/lib/eval/rubric.ts). The graded scoring system. Where IoU and the 60% Brier threshold live.
- [`lib/store.ts`](https://github.com/midimurphdesigns/forge/blob/main/lib/store.ts). The session store interface, the in-memory implementation, the Upstash-backed abort flag, and the registerLaneController hook. Where the global-signal vs local-actuator split is most visible.

## The trade-offs

The session store is in-memory in the live demo, which means resume across replicas degrades to occasional 404s on Vercel. The calibration log and the abort signal already moved to Upstash; the session store is a one-file follow-up. Prompt-caching breakpoints are wired on every system message but Anthropic's 1024-token minimum means they sit unused at this prompt size, which the live cost panel discloses honestly rather than padding the prompt to fake hits. The four subagent tools are fixture-backed; the real GitHub and Sentry adapters are a separate phase that doesn't change the architecture. Production use with write-capable tools (create_pr, send_message, charge_customer) would also need idempotency keys plus a transactional outbox plus saga compensation plus a durable execution layer like Vercel Workflows to make aborts safe in the presence of side effects. The architecture is shaped for those additions; the demo intentionally stops before them.

---
## anchor — AI-native product catalog: dual-surface pages, delegated-authority checkout, AEO instrumentation

URL: https://kevinmurphywebdev.com/portfolio/anchor
Role: Author · Applied AI
Year: 2026
Stack: TypeScript, Next.js 16, React 19, Vercel AI SDK, Anthropic SDK, Zod, Upstash, Tailwind v4
Summary: Public open-source AI-native commerce surface. Every product has a human page AND three statically-cached LLM-facing endpoints (markdown, JSON-LD, plain) optimized for citation, discovery, and agent purchase. A /.well-known/agents.json descriptor publishes endpoints, auth model, pricing-negotiation envelope, and rate limits in the Agentic Commerce Protocol shape. AEO instrumentation classifies 13 LLM crawlers by user-agent and logs every fetch to Redis via after() so cached responses still get observed. A delegated-authority checkout endpoint runs an eight-check pipeline (signature, expiry, agent binding, scope, nonce, idempotency) with HMAC-SHA256 and constant-time signature comparison, proven by a five-scenario test that asserts happy-path, replay, over-budget, wrong-SKU, and idempotent retry. A comparison agent with generative UI demonstrates structured-output dispatch: the model picks one of three shapes (spec table, pros/cons, recommendation) via a Zod discriminated union and React switch-renders the matching component, type-safe at every boundary.

> **Repo:** [github.com/midimurphdesigns/anchor](https://github.com/midimurphdesigns/anchor)
>
> **Live demo:** [anchor.kevinmurphywebdev.com](https://anchor.kevinmurphywebdev.com)
>
> **Read the full story:** [Building anchor](/blog/building-anchor)

Anchor is an AI-native product catalog. Every product has a human page AND three statically-cached LLM-facing endpoints. A `/.well-known/agents.json` descriptor publishes the site's capabilities in the Agentic Commerce Protocol shape. A delegated-authority checkout endpoint runs an eight-check pipeline before any charge fires. A live AEO dashboard classifies 13 known LLM crawlers by user-agent and shows the per-product fetch counts. <Stat>40</Stat> prerendered LLM-facing routes, <Stat>~5ms</Stat> TTFB on cache hits, <Stat>11/11</Stat> assertions pass on the checkout test suite.

## How it's built

Next.js 16 App Router with `cacheComponents: true`, TypeScript strict, zero `any`. The whole catalog of 10 products generates 30 LLM-facing static routes via `generateStaticParams`, three format-specific surfaces per slug (`/agent/markdown`, `/agent/json`, `/agent/plain`), plus 10 redirect entries on the canonical `/agent` URL. Bodies are pure functions of the in-memory catalog wrapped in `'use cache'` with `cacheLife('hours')` and `cacheTag('product:<slug>')`, so a sale invalidates exactly the one product entry on `revalidateTag` and leaves the other nine cached.

The product detail page uses Partial Prerendering. Product copy, specs, JSON-LD, and the buy CTA prerender at build time. A single dynamic Suspense hole reads `headers()` to opt out of static rendering and streams in the live agent-fetch tally from Redis at request time. Cached shell + live hole, served as one document.

## The agent surface

LLM-facing content is split across three format-specific routes so each one can prerender independently. `/agent/markdown` returns the combined block: a citation opening line (`Source: <canonical URL>, <brand> <name> is listed at <price>...`), a JSON-LD Schema.org Product/Offer in a fenced code block, then prose with specs as bullets and a closing canonical URL line. `/agent/json` returns just the JSON-LD. `/agent/plain` returns just the prose. Each format ships ten prerendered files, served from the edge cache.

Telemetry rides outside the body. The proxy (Next 16's renamed `middleware.ts`) runs at the edge on every `/agent` request regardless of cache status, classifies the User-Agent against 13 known LLM crawlers, and queues a Redis write via `after()`. Cache hits stay observed because the proxy still executes. The tradeoff that mattered: putting the logging inside the route handler would have missed every cache hit.

## Delegated-authority checkout

`POST /api/agent/checkout` runs an ordered eight-check pipeline. Signature verifies first (HMAC-SHA256 with constant-time comparison, `===` would leak signature bytes byte-by-byte via timing). Expiry second. Agent binding third. Scope fourth (action + SKU + maxCents must all match). Nonce (the token's `jti`) fifth, single-use via Redis. Idempotency-Key sixth, cached responses returned without re-running the pipeline. Inventory + floor-price seventh. Charge + tag invalidation eighth.

The order is cheapest-first so an attacker spamming malformed tokens never touches Redis. The crypto rejects them in ~1ms. Production-ready in shape, demo-ready in price (HMAC instead of asymmetric ed25519, the verification logic is identical either way, only the key model changes). Five test scenarios in [`scripts/test-checkout.ts`](https://github.com/midimurphdesigns/anchor/blob/main/scripts/test-checkout.ts) cover happy path, replay attack, over-budget, wrong-SKU, and idempotent retry; eleven assertions all pass.

## Generative UI with structured-output dispatch

A `/compare` page lets the user pick two products. The model decides which UI shape fits, side-by-side spec table for same-category rivals, pros/cons split for overlapping use cases, recommendation paragraph for unrelated products. Implemented with `streamObject` from AI SDK v6 against a Zod discriminated union (three shapes, each tagged with a `kind` literal). The client switches on `kind` and React-renders the matching component. Type-safe at every boundary; the model never emits markup, only structured fields.

## Self-documenting build

Every route's render mode is documented in [`/docs/rendering`](https://anchor.kevinmurphywebdev.com/docs/rendering), a single page that lists the full route table with the rationale for each choice (SSG vs PPR vs Dynamic) plus the ten Next 16 + AI SDK v6 primitives the build leans on, with file paths to grep for each. A dev-only render inspector overlays colored boundaries on every annotated region with a counterfactual savings panel that compares the current TTFB against a fully-dynamic baseline. Toggle it on; the architecture becomes visible.

## Artifacts worth reading

- [`lib/principal.ts`](https://github.com/midimurphdesigns/anchor/blob/main/lib/principal.ts). The eight-check verifier. HMAC, constant-time compare, ordered failure modes with stable codes for HTTP mapping.
- [`lib/agents-descriptor.ts`](https://github.com/midimurphdesigns/anchor/blob/main/lib/agents-descriptor.ts). The single source of truth for `/.well-known/agents.json`, `/llms.txt`, and the human `/agents` page. Documentation IS the implementation.
- [`proxy.ts`](https://github.com/midimurphdesigns/anchor/blob/main/proxy.ts). Cited-by attribution + AEO logging cross-cutting concerns. Where `after()` rides on cached routes.
- [`app/products/[slug]/agent/markdown/route.ts`](https://github.com/midimurphdesigns/anchor/blob/main/app/products/%5Bslug%5D/agent/markdown/route.ts). The static citation-shaped LLM surface; nine lines of route logic, every byte pure.
- [`lib/render-modes.ts`](https://github.com/midimurphdesigns/anchor/blob/main/lib/render-modes.ts). The render-mode catalog the docs page reads from. New routes get an entry here and the documentation updates automatically.

## The trade-offs

The HMAC token shape is a demo-grade simplification. A production deployment would use asymmetric signatures (ed25519) issued by the user's wallet, with anchor verifying via the user's public key, the eight-check logic is identical, only the key model changes. The catalog of ten products is fictional; pushing this to real ecommerce data would land via a Supabase loader behind the same `'use cache'` wrapper without changing the cache shape. The in-memory Redis fallback (for local dev without Upstash) is intentionally per-process and doesn't survive restarts; production needs Upstash configured for cross-instance state. The comparison agent uses Claude Haiku 4.5 at temperature 0.3 for fast structured output; a production version would A/B test the model + temperature pair against the eval scoreboard. The architecture is shaped for these additions; the demo intentionally stops before them.

---
## issuegraph — GitHub issue triage agent on LangGraph with LangSmith evals

URL: https://kevinmurphywebdev.com/portfolio/issuegraph
Role: Author · Applied AI
Year: 2026
Stack: TypeScript, Next.js 16, LangGraph, LangChain, LangSmith, Anthropic SDK, Zod, Upstash, Redis
Summary: Public open-source issue triage agent built as a LangGraph state machine. Classifies a GitHub issue with structured output and a confidence score, routes it to one of four specialist draft nodes via conditional edges, loops the reply through an LLM quality guard with a bounded redraft cycle, then applies a confidence gate: high-confidence results finalize automatically, low-confidence results pause the graph with interrupt() and wait for human approval. Checkpoints persist to Redis so paused runs resume across serverless instances. A LangSmith eval suite runs a labeled golden set through the full graph with a deterministic category evaluator plus an LLM-as-judge on draft quality, and a calibration report checks the classifier's stated confidence against actual accuracy with a Brier score and reliability buckets. The live demo streams every node to the page as it executes.

> **Repo:** [github.com/midimurphdesigns/issuegraph](https://github.com/midimurphdesigns/issuegraph)
>
> **Live demo:** [issuegraph.kevinmurphywebdev.com](https://issuegraph.kevinmurphywebdev.com)
>
> **Read the full story:** [Building issuegraph](/blog/building-issuegraph)

issuegraph is a GitHub issue triage agent built as a LangGraph state machine. It classifies an issue, routes it to a specialist drafter, loops the reply through a quality guard, and pauses for human approval when its own confidence is low. The live demo runs the real graph server-side and streams every node to the page as it executes. <Stat>7</Stat> graph nodes, <Stat>2</Stat> evaluators over a labeled golden set, <Stat>1</Stat> calibration report checking whether the model's confidence is honest.

## How it's built

Next.js 16 App Router, TypeScript strict, zero `any`. The graph is `@langchain/langgraph`, the model calls are `@langchain/anthropic`, evals and tracing are `langsmith`. Zod validates every LLM boundary and both API request bodies. Upstash provides the rate limiter and, through a derived TCP connection, the Redis checkpointer that lets paused graphs resume across serverless instances.

The classifier is a LangChain chain: a prompt template piped into a model with structured output. The schema forces a category and a confidence score out of every call, so the graph downstream can route on typed data instead of parsing prose.

```ts
const classifierChain = prompt.pipe(
  model.withStructuredOutput(ClassificationSchema, { name: "classify_issue" }),
);
// returns { category: "bug" | "feature" | "docs" | "question",
//           confidence: number, reasoning: string }
```

The graph wires seven nodes with conditional edges. One router picks the specialist drafter by category. A second router loops rejected drafts back for another pass, bounded so the guard cannot spin forever.

```ts
const builder = new StateGraph(TriageState)
  .addNode("classify", classifyNode)
  .addNode("draftBug", draftBugNode)
  .addNode("draftFeature", draftFeatureNode)
  .addNode("draftDocs", draftDocsNode)
  .addNode("draftQuestion", draftQuestionNode)
  .addNode("guard", guardNode)
  .addNode("gate", gateNode)
  .addEdge(START, "classify")
  .addConditionalEdges("classify", routeByCategory, { /* category -> drafter */ })
  .addConditionalEdges("guard", afterGuard, { /* approved -> gate, rejected -> redraft */ })
  .addEdge("gate", END);
```

The confidence gate is where the human enters. Above the threshold the run finalizes on its own. Below it, the node calls `interrupt()`, which checkpoints the whole graph state and stops. The run resumes later, possibly on a different serverless instance, when a human sends back a decision.

```ts
function gateNode(state: TriageStateType) {
  const confidence = state.classification?.confidence ?? 0;
  if (confidence >= CONFIDENCE_GATE) return { status: "auto-finalized" };

  const decision = interrupt({
    reason: "low confidence, human approval required",
    confidence,
    draft: state.draft,
  }) as { approved: boolean };

  return { status: decision.approved ? "human-approved" : "human-rejected" };
}
```

Resuming is one call: `graph.invoke(new Command({ resume: { approved } }), config)`. The resume value is an object on purpose. LangGraph treats a bare falsy resume as missing input and throws, which I learned by hitting it.

## The LangSmith leg

LangSmith does two jobs here: it records what ran, and it grades how well.

Recording is free. Set two environment variables and every chain and graph invocation ships a trace: the full tree of model calls with inputs, outputs, token counts, and latency per step. When the guard rejected a draft and the graph looped back, the trace shows the second draft call nested exactly where it happened. The live demo page mirrors this same shape in the browser, streaming one event per executed node, so what you watch on screen is the trace structure LangSmith records server-side.

Grading is the eval suite. A labeled golden set uploads as a LangSmith dataset, `evaluate()` runs the full graph over every example, and two evaluators score each result. Category accuracy is a deterministic exact match. Draft quality is an LLM judge, because a free-form reply has no single correct answer to compare against.

```ts
const experiment = await evaluate(target, {
  data: "issuegraph-golden",
  evaluators: [categoryAccuracy, draftQuality],
  experimentPrefix: "issuegraph",
});
```

The calibration report is the part I care most about. The classifier states a confidence with every call, and that number is a claim, not a fact. The report checks the claim against reality: a Brier score across all predictions plus a reliability table that buckets stated confidence and compares it to actual accuracy. This is the same discipline forge applies to its debugging lanes, rebuilt on LangSmith eval data.

```
── CALIBRATION REPORT ──────────────────────────────────
samples:      8
Brier score:  0.003  (0 = perfect, 0.25 = coin flip)

reliability by confidence bucket:
  bucket      n   claimed  actual   verdict
  0.9-1.0     8   0.95     1.00    calibrated
```

A clean report on eight easy examples flatters the model, and the honest read is that the golden set is the weakness. The interesting work is growing it with production failures until the reliability table starts telling the truth about where the classifier overclaims.

## The demo hardening

The public page triages curated preset issues only. Arbitrary input on a public LLM endpoint invites prompt injection and unbounded spend, so no visitor text ever reaches a prompt. Per-IP rate limits and a global daily budget cap the spend, both failing closed if the limiter is unconfigured in production. Resume requests run no model call, so they stay rate-limited but never burn the daily budget. Thread ids are UUIDs, checkpoints expire after an hour, and production error events are generic while the real errors stay in server logs.

## Artifacts worth reading

- [`src/graph.ts`](https://github.com/midimurphdesigns/issuegraph/blob/main/src/graph.ts). The state machine: routing, the guard loop, the confidence gate with `interrupt()`.
- [`src/run-evals.ts`](https://github.com/midimurphdesigns/issuegraph/blob/main/src/run-evals.ts). Dataset upload, `evaluate()`, and the calibration snapshot the demo page renders.
- [`src/calibration.ts`](https://github.com/midimurphdesigns/issuegraph/blob/main/src/calibration.ts). Brier score and reliability buckets in plain TypeScript, unit tested.
- [`src/demo/checkpointer.ts`](https://github.com/midimurphdesigns/issuegraph/blob/main/src/demo/checkpointer.ts). Why serverless interrupts need Redis, and the Upstash env-var derivation.

## The trade-offs

The golden set is eight clean examples, which proves the eval plumbing rather than the classifier. The guard and the judge share a model family with the drafters, so the grading has a family bias a production system would break by judging with a different provider. Tracing stays off in production to hold the free LangSmith tier; runs trace locally and in evals. And the CLI accepts any public GitHub issue URL while the hosted demo deliberately does not.

---
## tablesalt — CSV agent with generative UI, reasoning trace, and live eval scoreboard

URL: https://kevinmurphywebdev.com/portfolio/tablesalt
Role: Author · Product Engineer
Year: 2026
Stack: Next.js 16, React 19, TypeScript, Tailwind v4, Vercel AI SDK, Vercel AI Gateway, DuckDB-WASM, streamfield
Summary: Public open-source data-exploration agent. Drop a CSV, ask a natural-language question, see generative UI — five render kinds (table, bar, line, stat, list) chosen by the model. text-to-SQL via the Vercel AI SDK + Vercel AI Gateway over DuckDB-WASM running in-browser; zero backend. The agent emits a 4-step reasoning trace before its answer; the eval scoreboard (12 labeled NYC-311 cases, render-kind / SQL-executes / SQL-semantic-match scored with live cost + latency) runs on demand from the front page. Consumes streamfield@^0.1.0 from npm for its streaming reasoning UI.

> **Repo:** [github.com/midimurphdesigns/tablesalt](https://github.com/midimurphdesigns/tablesalt)
>
> **Live demo:** [tablesalt.kevinmurphywebdev.com](https://tablesalt.kevinmurphywebdev.com)
>
> **Read the full story:** [Building tablesalt](/blog/building-tablesalt)

Drop a CSV. Ask a question. See generative UI. <Stat>5</Stat> render kinds the agent picks from (table, bar, line, stat, list) chosen by the model based on the shape of the answer. text-to-SQL via the Vercel AI SDK routed through the Vercel AI Gateway, running over DuckDB-WASM entirely in-browser. No upload, no backend, no signup. The eval scoreboard runs live from the front page.

## How it's built

Next.js 16 + React 19 + TypeScript strict + Tailwind v4 + Vercel AI SDK v6 + Vercel AI Gateway. Two edge routes (`/api/agent`, `/api/eval`) are the only server surface; everything else is client-side. The client uses `@duckdb/duckdb-wasm` to parse and query CSVs in a Web Worker, so visitor data never leaves the browser. The model streams back a Zod-validated JSON object that leads with a 4-step reasoning trace (`profile_schema`, `pick_render_kind`, `draft_sql`, `validate_sql`), then the final SQL + render kind + caption. The client guards the SQL read-only and routes the result to the right render component with intentional reveal physics. The streaming reasoning summary is powered by [streamfield](/portfolio/streamfield). tablesalt is its first public npm consumer.

## Live eval scoreboard

<Stat>12</Stat> labeled NYC 311 cases, scored on three axes: render-kind correct, SQL executes against an in-process corpus, SQL semantically matches the expected query. Press the button and the eval runs against the live model right now: per-case latency, per-case cost, and final aggregate accuracy + total cost + per-case mean cost all stream in. No hardcoded numbers anywhere. Rate-limited at one run per IP per hour via Upstash Redis so the button is bounded.

## What it demonstrates

- **Generative UI as the response surface.** Five render kinds means the agent picks how to answer, not just what to answer.
- **Agent reasoning trace.** A four-step thought process streams live before the final answer lands.
- **Evals as part of the product, not a hidden test suite.** The scoreboard runs against the live model on demand, surfaces per-case token cost, and shows the accuracy numbers I'd otherwise be tempted to hide.
- **Frontend craft.** Bar draws, polyline reveals, stat-card type weight, schema-profile cascade, streaming reasoning via the npm-published streamfield primitive. Each piece of motion exists to communicate state, not to decorate.
- **Zero-backend product.** DuckDB-WASM means no upload, no privacy story to write, no signup wall to bounce visitors off.

## What I wanted that chat boxes don't give me

Most AI-for-data demos answer in a chat bubble. The bubble is the wrong container. I wanted the answer to *be* the chart, sized and labeled and animated into place, with the SQL one click away if you want to verify it. Picking the render kind is the agent's most consequential decision; making the picked surface look intentional is the frontend's job.

## Open source

MIT-licensed. The whole repo is one `pnpm install` away.

---
## streamfield — React primitive for partial-object stream UIs

URL: https://kevinmurphywebdev.com/portfolio/streamfield
Role: Author · Product Engineer
Year: 2026
Stack: TypeScript, React, tsup, Vercel AI SDK, Vercel, Next.js 16
Summary: Public open-source React primitive (~150 LOC) for rendering Vercel AI SDK partial-object streams with field-by-field reveal physics. Diffs successive snapshots, derives per-field pending -> streaming -> complete state, hands the state to children via render prop. Three opinionated CSS variants ship in the package; consumers can ignore them and style state transitions themselves via a data attribute. Published to npm; extracted from tablesalt. Docs site at streamfield.kevinmurphywebdev.com runs on Next.js 16 deployed to Vercel.

> **Install:** `npm install streamfield`
>
> **Repo:** [github.com/midimurphdesigns/streamfield](https://github.com/midimurphdesigns/streamfield)
>
> **Live playground:** [streamfield.kevinmurphywebdev.com](https://streamfield.kevinmurphywebdev.com)
>
> **Read the full story:** [Building streamfield](/blog/building-streamfield)

A small React library for rendering Vercel AI SDK partial-object streams without the flicker. One component, one hook, three variants. Render-prop API. Published to npm; extracted from [tablesalt](/portfolio/tablesalt), and used in production by tablesalt itself.

## The problem it solves

If you use `streamObject` from the Vercel AI SDK, your fields snap into place jarringly as React re-renders. CSS transitions don't help: the DOM was always there; only the text changed. CSS animates property changes, not innerText swaps.

`streamfield` diffs successive snapshots, derives per-field `pending`, `streaming`, `complete` state, hands the state to children via render prop. You style transitions however you want, or import the bundled `streamfield/styles.css` for three opinionated variants: `cascade`, `shimmer`, `underline-fill`.

## Usage

```tsx
import { StreamingReveal } from 'streamfield';
import 'streamfield/styles.css';

<StreamingReveal stream={partial} variant="cascade" done={done}>
  {(f) => (
    <article>
      <h2 data-streamfield-state={f.title?.state}>{f.title?.value}</h2>
      <p data-streamfield-state={f.summary?.state}>{f.summary?.value}</p>
    </article>
  )}
</StreamingReveal>
```

## How it's built

TypeScript strict, tsup build (ESM and CJS bundles with type definitions), React 18+ peer dep, ~6 KB CSS file if you opt into the bundled defaults. The `useFieldStates` hook is exported separately for consumers who want the state without the render-prop wrapper.

The docs site at [streamfield.kevinmurphywebdev.com](https://streamfield.kevinmurphywebdev.com) ships an interactive playground. A slider scrubs through synthetic partial-object frames so you can preview the three variants side-by-side without writing a single line of code.

## Used in production

tablesalt depends on `streamfield@^0.1.0` from npm. Its final streaming reasoning summary is rendered through `<StreamingReveal variant="cascade">`. Watch the agent answer a question on [tablesalt.kevinmurphywebdev.com](https://tablesalt.kevinmurphywebdev.com). The words landing one-by-one are streamfield in the wild.

## Open source

MIT-licensed. `npm install streamfield`.

---
## fedbench — LLM eval harness for grounded Q&A

URL: https://kevinmurphywebdev.com/portfolio/fedbench
Role: Author · Applied AI
Year: 2026
Stack: TypeScript, Bun, Anthropic SDK, MCP, BM25, LLM-as-judge
Summary: Public open-source evaluation harness for grounded Q&A agents over policy PDFs. Two side-by-side federal corpora (Medicare + OSHA, 21 verified Q&A pairs total), BM25 retrieval, fallback ladder, deterministic citation-check + Opus 4.7 LLM-as-judge, and a no-API-key replay path so visitors can demo the scoring pipeline in under a second.

> **Repo:** [github.com/midimurphdesigns/fedbench](https://github.com/midimurphdesigns/fedbench)
>
> **Live demo:** [fedbench.kevinmurphywebdev.com](https://fedbench.kevinmurphywebdev.com)
>
> **Read the full story:** [Building fedbench](/blog/building-fedbench)

An open-source evaluation harness for grounded LLM Q&A. The agent reads federal documents and answers questions about them; the harness scores hallucination, citation accuracy, and refusal discipline as first-class metrics. <Stat>21</Stat> verified Q&A pairs across two side-by-side public corpora (Medicare and OSHA), <Stat>~$0.025</Stat> per pair, replay path that runs in <Stat>1 second</Stat> with no API key.

## How it's built

Bun + TypeScript strict, Anthropic SDK, BM25 retrieval over chunked PDFs. Three layered checks per question: a deterministic citation check (does the agent's claimed page actually contain its answer's load-bearing tokens?), an LLM-as-judge run on Opus 4.7 (a stronger model than the agent's Sonnet 4.6, on purpose), and refusal correctness on an out-of-corpus split. Every model call goes through a Sonnet → Haiku fallback ladder with full provenance attached to the response: which rung answered, latency, cost, attempts that bailed.

## Artifacts worth reading

- The [agent prompt + system rules](https://github.com/midimurphdesigns/fedbench/blob/main/src/agent/answer.ts) that enforce citation format and refusal phrasing
- The [judge harness](https://github.com/midimurphdesigns/fedbench/blob/main/src/eval/judge.ts) that grades whether a cited chunk supports the agent's claim
- The [recordings file](https://github.com/midimurphdesigns/fedbench/blob/main/eval/recordings/medicare.jsonl) that powers the no-API-key replay path

## The trade-offs

Custom evals over verified corpora are slower to author than vendor evals over synthetic data — but they catch the failures that actually break trust. The harness is opinionated on purpose: deterministic checks before LLM checks, recordings as audit artifacts, provenance on every call.

---
## fieldops-mcp — agent tool server for a field-services workflow

URL: https://kevinmurphywebdev.com/portfolio/fieldops-mcp
Role: Author · Applied AI
Year: 2026
Stack: TypeScript, Bun, MCP TS SDK, Zod, Claude Desktop
Summary: Public open-source MCP server that exposes a small-business field-services dispatcher workflow — triage, scheduling, customer comms, utilization roll-up, human escalation — as six agent tools an LLM client can drive end-to-end. Each tool exercises a distinct shape a product engineer integrating AI has to design for: read, search, mutation with typed conflict errors, composition, aggregation, and refusal/escalation.

> **Repo:** [github.com/midimurphdesigns/fieldops-mcp](https://github.com/midimurphdesigns/fieldops-mcp)
>
> **Live showcase:** [fieldops-mcp.kevinmurphywebdev.com](https://fieldops-mcp.kevinmurphywebdev.com)
>
> **Read the full story:** [Building fieldops-mcp](/blog/building-fieldops-mcp)

A Model Context Protocol server that exposes a small-business field-services dispatcher workflow as agent tools an LLM client can drive end-to-end. <Stat>6</Stat> tools, <Stat>6</Stat> structurally distinct shapes: read, search-and-rank, mutation with typed errors, composition, aggregation, and human escalation. The interesting work isn't the plumbing; it's choosing which tools to expose at all.

## How it's built

Bun + TypeScript strict, MCP TypeScript SDK, Zod-validated tool inputs, typed error envelopes (`NotFoundError`, `ConflictError`, `ValidationError` extending `ToolError`). Each tool unlocks a different capability: `find_available_techs` is search-and-rank with a join the model would otherwise do badly by hand; `assign_job` is mutation with typed conflict errors so the model has to read failures it would otherwise ignore; `draft_customer_message` is composition that pulls real customer + tech + job data into a draft (and deliberately does not send); `flag_for_human` is constructive refusal as a tool, queue-routable instead of swallowed in chat.

## Artifacts worth reading

- The [tool registry](https://github.com/midimurphdesigns/fieldops-mcp/blob/main/src/tools/index.ts) that wires the six tools to the MCP server
- The [conflict-error path](https://github.com/midimurphdesigns/fieldops-mcp/blob/main/src/tools/assign-job.ts) for mutation tools. The part most agent integrations get wrong.
- The [captured Claude transcripts](https://github.com/midimurphdesigns/fieldops-mcp/blob/main/docs/TRANSCRIPTS.md) showing the six tools in real end-to-end flow

## The trade-offs

Tool-surface design is the part of agent engineering most projects skip. It's also the part that decides whether the agent is a feature you can ship or a feature you can demo. fieldops-mcp is the smallest server that demonstrates the discipline.

---
## grant-pilot — federal-grants agent with sub-agent orchestration

URL: https://kevinmurphywebdev.com/portfolio/grant-pilot
Role: Author · Applied AI
Year: 2026
Stack: TypeScript, Bun, Anthropic SDK, Next.js 16, Vercel, Upstash
Summary: Public open-source agent that helps a small business or nonprofit discover federal grants they qualify for and drafts an application skeleton — orchestrating three specialist sub-agents (discovery, eligibility, drafter) over live federal opportunity and entity-registration APIs, with per-sub-agent fallback ladder, structured-failure routing, and a hosted demo hardened with a 5-intent allowlist, daily budget cap, and per-IP rate limit.

> **Repo:** [github.com/midimurphdesigns/grant-pilot](https://github.com/midimurphdesigns/grant-pilot)
>
> **Live demo:** [grant-pilot.kevinmurphywebdev.com](https://grant-pilot.kevinmurphywebdev.com)
>
> **Read the full story:** [Building grant-pilot](/blog/building-grant-pilot)

A multi-turn agent that helps a small business or nonprofit find federal grants they qualify for and drafts the skeleton of an application. A planner dispatches three specialist sub-agents (discovery, eligibility, drafter) over the federal grants opportunity API and the federal entity-registration API. <Stat>3</Stat> sub-agents, <Stat>3</Stat> tools, <Stat>~$0.05</Stat> per run, hosted with a daily budget cap and per-IP rate limit so strangers can run it without burning the bill.

## How it's built

Bun + TypeScript strict, Vercel AI SDK (`generateObject` for Discovery + Eligibility, `streamObject` for the prose-heavy Drafter; [see the migration writeup](/blog/building-grant-pilot) for why I shipped on the raw Anthropic SDK first and ported once the case was clear), Zod-validated tool boundaries, real integrations against the federal grants and entity-registration APIs. The planner runs a bounded multi-turn loop: discovery derives a keyword query and ranks 5 candidates 0 to 100; eligibility fetches full grant detail + an optional SAM check and returns a verdict grounded in the eligibility text; drafter produces a structured application skeleton (sections + applicant prompts + watch-outs, never prose). Each sub-agent has its own fallback ladder (Sonnet 4.6 then Haiku 4.5, ported from [fedbench](/portfolio/fedbench)). The hosted demo is hardened with a $3/day budget cap, a 5-runs/hour-per-IP rate limit (Upstash Redis), and bounded enum + regex inputs that neutralize the prompt-injection surface.

## Artifacts worth reading

- The [planner](https://github.com/midimurphdesigns/grant-pilot/blob/main/src/agent/planner.ts) that decomposes intent and dispatches sub-agents. Never throws, structured failures route as values.
- The [SAM-registration hard gate](https://github.com/midimurphdesigns/grant-pilot/blob/main/src/agents/eligibility.ts). The only place a deterministic check overrides the LLM verdict.
- The [streaming `/api/run` route handler](https://github.com/midimurphdesigns/grant-pilot/blob/main/web/app/api/run/route.ts) with NDJSON streaming, budget cap, and rate-limit guardrails.

## The trade-offs

Sub-agent orchestration is more code to maintain than a single agent with a long prompt. It's also the thing that makes a multi-turn agent debuggable, observable, and cost-bounded in production. Three sub-agents was deliberate. Adding a fourth would be framework creep without commensurate user value at v0.1. The cap itself is the signal.

---
## kev-o — grounded RAG chatbot trained on my own writing

URL: https://kevinmurphywebdev.com/portfolio/kev-o
Role: Author · Applied AI
Year: 2026
Stack: TypeScript, Next.js 16, Vercel AI SDK, Anthropic SDK, Voyage rerank, BM25, Upstash, Tailwind v4
Summary: Public open-source grounded chatbot answering questions about my work using only my public corpus (blog, case studies, resume, OSS READMEs). Hybrid retrieval — BM25 candidate pool plus Voyage cross-encoder rerank — feeds Claude Sonnet 4.6 streamed through the Vercel AI SDK. Three surfaces share one brain: a standalone subdomain, a global Command-K palette, and inline punch-ins at the foot of every curated entry. Hardened with per-IP rate limit, daily USD spend cap, and an owner-bypass route with timing-safe key comparison and per-IP attempt cap.

> **Repo:** [github.com/midimurphdesigns/kev-o-ai-search](https://github.com/midimurphdesigns/kev-o-ai-search)
>
> **Live:** [kev-o.kevinmurphywebdev.com](https://kev-o.kevinmurphywebdev.com)
>
> **Read the full story:** [Building Kev-O](/blog/building-kev-o)

Kev-O is a grounded chatbot that answers questions about my work using only the public corpus I've written. Blog posts, project case studies, resume, About page, the READMEs of my open-source repos. He cites his receipts. He refuses to invent. <Stat>212</Stat> chunks across 4 sources, <Stat>3</Stat> surfaces sharing one brain, <Stat>~$0.005</Stat> per turn with cached system prompt.

## How it's built

Next.js 16 App Router on Vercel, TypeScript strict, Vercel AI SDK (`streamText` plus `useChat`) on `@ai-sdk/anthropic` with Claude Sonnet 4.6. Hybrid retrieval: BM25 candidate pool over the full corpus (ported from [fedbench](/portfolio/fedbench), k1=1.5, b=0.75, ~3ms in-memory) then Voyage `voyage-rerank-2.5` cross-encoder rerank narrowing 20 lexical candidates to the top 6 semantic winners. For inline punch-ins the page body itself is forged as a synthetic passage at position 0 so Kev-O is most likely to cite the article the visitor is reading. The corpus is built at deploy time via the [mdx-corpus](https://github.com/midimurphdesigns/mdx-corpus) primitive I extracted from this build. The hosted surface is hardened with a $10/UTC-day spend cap (charged post-stream against actual reported token usage, not estimated), a 50-req/hour-per-IP rate limit via Upstash Redis, and an owner-bypass route that fails closed if the admin key is unset, uses Node's `timingSafeEqual` for comparison, returns 404 (not 401) on wrong keys, and rate-limits admin-credential presentations at 5/hour BEFORE the key check so an attacker exhausts their budget regardless of guess outcome.

## Three surfaces, one brain

The chat lives in three places because the visitor's intent is different in each. The subdomain is a standalone full-page conversation, the URL shared as a direct link. The Command-K palette puts Kev-O at the top of the global keyboard surface on every page of the main site. The inline punch-ins sit at the foot of every curated blog post and project case study with the page already as ground truth. All three call the same `/api/kev-o` endpoint; the subdomain is a thin proxy. One brain, three surfaces, zero divergence by construction.

## Artifacts worth reading

- The [retrieval pipeline](https://github.com/midimurphdesigns/kev-o-ai-search/blob/main/src/lib/kev-o-retrieve.ts). BM25 plus Voyage rerank with graceful fallback when the rerank API is missing.
- The [BM25 implementation](https://github.com/midimurphdesigns/kev-o-ai-search/blob/main/src/lib/kev-o-bm25.ts). Ported from fedbench, decoupled from its corpus-path machinery, retuned for in-memory chunks.
- The [owner-bypass route](https://github.com/midimurphdesigns/kev-o-ai-search/blob/main/src/app/api/kev-o-admin/route.ts). The security posture I'm proudest of in the build (lives on the main-site repo; the canonical reference).
- The extracted [mdx-corpus](https://github.com/midimurphdesigns/mdx-corpus) primitive. The part I'd most recommend reading because it's the design judgment under load.

## The trade-offs

Three surfaces is more code than one. It's also the thing that lets the bot meet the visitor where they are: deep-evaluating the case study they just read, browsing for a quick answer, sharing the URL with a colleague. The shared `/api/kev-o` endpoint and the inline-punch-in page-context grounding are how I prevent that surface count from becoming three slightly different experiences. The cost shape says the same thing: BM25 is free, rerank costs a fraction of a cent, generation dominates. Optimizing retrieval further would be optimizing the wrong axis.

---
## mdx-corpus — npm package: MDX directory to retrieval-ready corpus

URL: https://kevinmurphywebdev.com/portfolio/mdx-corpus
Role: Author · Applied AI
Year: 2026
Stack: TypeScript, tsup, vitest, MDX, npm
Summary: Public npm package that turns a directory of MDX files into a retrieval-ready JSON corpus for RAG pipelines. Parses frontmatter, strips JSX components while preserving their text children, chunks on heading boundaries with a paragraph-split fallback for long sections, emits passages carrying source URL and metadata for citation. ~300 lines of TypeScript, 19 tests, dual ESM/CJS build via tsup, zero runtime dependencies. Extracted from the kev-o-ai-search build because the parse-and-chunk step is reusable across surfaces; the package deliberately refuses to grow into embeddings, vector storage, or retrieval logic.

> **npm:** `npm install mdx-corpus`
>
> **Repo:** [github.com/midimurphdesigns/mdx-corpus](https://github.com/midimurphdesigns/mdx-corpus)
>
> **Used in:** [Kev-O](https://github.com/midimurphdesigns/kev-o-ai-search) and this site's own corpus build.
>
> **Read the full story:** [Building mdx-corpus](/blog/building-mdx-corpus)

A small npm package that turns a directory of MDX files into a retrieval-ready JSON corpus. Parses the frontmatter, strips JSX components while keeping their text content, chunks on heading boundaries with a paragraph-split fallback for long sections, and emits clean passages carrying source URL and metadata for citation. <Stat>~300</Stat> lines of TypeScript, <Stat>19</Stat> tests, dual ESM/CJS build, zero runtime dependencies. Pure file-in / JSON-out.

## How it's built

TypeScript strict, tsup for the dual ESM/CJS build, vitest for the test suite. The package's three responsibilities are split across three small modules: `parse.ts` (frontmatter and JSX stripping), `chunk.ts` (heading-based chunking with character-budget fallback), and `index.ts` (the public `buildCorpus` API that walks a directory tree and composes the other two). JSX handling is hand-rolled instead of pulling in a full MDX AST: the package tracks tag depth and strips only the opening and closing wrappers, preserving everything between them so a `<Callout>The point.</Callout>` survives as `The point.` in the corpus.

## What this is NOT

By design, the package refuses three jobs that would balloon its surface: no embedding generation, no vector storage, no retrieval logic. Voyage, OpenAI, Cohere, pgvector, Pinecone, BM25, cosine, hybrid rerank: all downstream of this package. Every refusal is a thing the package doesn't have to maintain, version, or document. Restraint is the design.

## Artifacts worth reading

- The [chunking strategy](https://github.com/midimurphdesigns/mdx-corpus/blob/main/src/chunk.ts). Heading-first, paragraph-fallback for long sections, metadata carried through so retrieval can cite the right URL even after a section gets split.
- The [JSX-stripping walker](https://github.com/midimurphdesigns/mdx-corpus/blob/main/src/parse.ts). Small hand-rolled implementation that handles components-with-children correctly without a heavy parser dependency.
- The [test suite](https://github.com/midimurphdesigns/mdx-corpus/tree/main/src/__tests__). Covers the gnarly cases: frontmatter with quote characters in values, code fences containing what looks like a heading, JSX attributes spanning multiple lines.

## The trade-offs

The package could grow to own embedding, retrieval, even reranking. It chooses not to. The benefit is sharp boundaries: a stranger evaluating the source can read it in fifteen minutes and see exactly what they're buying. The cost is that you write a little more glue in your application code to wire embeddings and retrieval on top. That trade is correct for the role: this is a sharp tool, not a framework, and most package-design failures come from adding surface before the second consumer asks.

---
## kevinmurphywebdev.com — personal site + portfolio + resume

URL: https://kevinmurphywebdev.com/portfolio/kevinmurphywebdev
Role: Author · designer · engineer
Year: 2026
Stack: Next.js 16, React 19, TypeScript, Tailwind v4, MDX, Vercel
Summary: Personal site, portfolio, blog, and live resume — Next.js 16 App Router with Server Components, MDX-driven case studies and blog posts, programmatic resume rendered from a single JSON source of truth, and a per-build PDF export pipeline (Puppeteer over the rendered /resume route) so the downloadable PDF can never drift from the live site. Lighthouse 95+ across all four scores; deployed on Vercel.

> **Live site:** [kevinmurphywebdev.com](https://kevinmurphywebdev.com)
>
> **Source:** [github.com/midimurphdesigns](https://github.com/midimurphdesigns) (private; happy to share the brand-system + ADR docs on request)

The site you're reading. Built by hand on Next.js 16's App Router with React Server Components, Tailwind v4's CSS-first design tokens, MDX-driven content, and a programmatic resume rendered from a single JSON source of truth. <Stat>95+</Stat> Lighthouse on desktop across all four scores. Per-build PDF export of the resume so the downloadable file can never drift from the live page.

## How it's built

Server Components by default. `"use client"` only appears where it's required (cursor, smooth scroll, demo subdomains). Tailwind v4's CSS-first config sits in `app/globals.css` via `@theme`, so design tokens are the source of truth for both runtime CSS and tooling that needs to read them. No `tailwind.config.js`. Three-tier type hierarchy locked in an ADR (Migra italic for display ≥32px, Space Grotesk for body, Geist Mono for metadata). Single cyan accent on near-black canvas; no glass, no gradient mesh, no card grid. The resume page reads from one `resume.json` file; a postbuild Puppeteer hook re-renders the live `/resume` route as a PDF on every Vercel deploy, so the download is always the live page.

## Artifacts worth reading

- The [decisions log](https://github.com/midimurphdesigns) (28+ ADRs covering type system, motion, color, resume export, demo subdomains)
- The three hosted demo subdomains: [grant-pilot](/portfolio/grant-pilot), [fedbench](/portfolio/fedbench), [fieldops-mcp](/portfolio/fieldops-mcp)
- The site's own [/demos index](/demos) and [/blog feed](/blog)

## The trade-offs

Hand-building every primitive (KMLogo, Cursor, Hero, Header, the resume renderer, the OG-image route) takes longer than dropping a template. The trade is that nothing on the site is a vendor's idea of how it should look. It's a place I can iterate on without fighting somebody else's defaults.

---
## PoolRM — internal staffing-intelligence platform

URL: https://kevinmurphywebdev.com/portfolio/poolrm
Role: Lead Senior Engineer
Year: 2025–Present
Stack: NestJS, Node.js, TypeScript, PostgreSQL, AWS Fargate, AWS CDK, Prisma
Summary: Lead senior engineer on PoolRM, an internal firm-initiative platform that matches open project staffing needs to consultant availability and skills, replacing a slow manual process. Built AI-first with Claude Code from day one: senior designers build the frontend AI-assisted while a principal engineer owns business requirements and I lead the backend and the AI-development workflow. Designed a NestJS API on AWS Fargate behind an Application Load Balancer with an RDS PostgreSQL database, provisioned via AWS CDK (ECR, S3, CloudFront), using Prisma, Zod, and TanStack Query across an npm-workspaces monorepo. Authored a cross-compatible Claude Code + GitHub Copilot context system so AI coding quality and developer experience stay uniform across machines, sessions, and tools, and introduced PRD-driven development influenced by BMAD with a custom implementation. Roughly 300 weekly users; improved data integrity in charge-code-to-project mapping.

PoolRM is an internal firm-initiative platform that matches open project staffing needs to consultant availability and skills. The process it replaces was manual and slow: leads hunting for who was free, who had the right skills, and which projects needed staffing, across spreadsheets and conversations. PoolRM turns that into a system. Roughly <Stat>300</Stat> people use it each week.

I'm the lead senior engineer. A principal engineer owns the business requirements, senior designers build the frontend AI-assisted, and I lead the backend and the AI-development workflow that keeps the whole team moving.

## Built AI-first, from day one

PoolRM was built with Claude Code from the first commit. That's not a footnote, it changes how the team works. Designers who aren't career backend engineers are shipping real frontend code AI-assisted, and my job is to smooth the rough edges: git workflow, code review, and the guardrails that keep AI-generated code consistent and correct across a team.

The piece I'm proudest of is a **cross-compatible context system for Claude Code and GitHub Copilot in a single codebase**. The problem it solves: AI coding quality drifts when every developer has a different setup, and it drifts again between sessions and between tools. The context system gives everyone a uniform experience regardless of which machine they're on, which session they're in, or whether they reach for Claude Code or Copilot. The AI has the same project understanding every time, so its output quality stays high and consistent instead of degrading at the edges.

On top of that I introduced **PRD-driven development**, taking influence from BMAD while implementing my own approach to fit how this team actually works. Requirements become structured documents the AI and the engineers both work from, so the gap between "what was asked for" and "what got built" stays small.

## The backend

I designed and built the backend: a **NestJS API on AWS Fargate**, sitting behind an Application Load Balancer, talking to an **RDS PostgreSQL** database. The whole stack is provisioned with **AWS CDK** (ECR for the container images, S3 and CloudFront for static delivery), so the infrastructure is code and reproducible.

The application layer uses **Prisma** for type-safe database access, **Zod** for validation at the boundaries, and **TanStack Query** on the client, all in an **npm-workspaces monorepo** so shared types flow cleanly between the API and the frontend. TypeScript end to end, tested with Jest and Vitest plus React Testing Library.

## The outcome

PoolRM makes staffing legible. Leads can see what needs staffing and who fits, instead of reconstructing it by hand. It also improved data integrity in how charge codes map to projects, cleaning up a source of error in the old workflow. It's in weekly use across the practice.

---
## State of Michigan — Unemployment Insurance

URL: https://kevinmurphywebdev.com/portfolio/michigan-ui
Role: Senior Engineer
Year: 2025–Present
Stack: ReactJS, TypeScript, Performance, Design Systems
Summary: Led a 15-developer offshore team building the React frontend for Michigan's unemployment insurance modernization (2,000+ pages, 270K residents) on Deloitte's LIFT framework. Personally drove WCAG 2.1 AA conformance and a sidebar performance overhaul (custom ms-precision metrics, O(n²) -> O(n), context-state caching for permissions; 30% avg improvement).

The Michigan Unemployment Insurance Agency runs one of the largest claimant-facing systems in the country: <Stat>270,000</Stat> residents at peak, every one of them under financial pressure. The frontend that mediates that experience matters in a way most marketing software doesn't.

I worked on the React modernization of the claimant and caseworker portals. My primary contribution was accessibility: bringing the system into strict WCAG conformance across <Stat>2,000+</Stat> pages, alongside performance work on hot paths like the navbar, and a maintainability pass that pulled sprawling logic back into a single source.

## Accessibility was the work

The system serves the most digitally vulnerable members of the state: low-vision users, screen-reader users, motor-impairment users on assistive switch devices, users on five-year-old phones over flaky connections. If the frontend doesn't meet them where they are, the entire benefit-delivery system fails for the people who need it most.

What that meant in practice:

- **<Stat>3,000+</Stat> accessibility defects resolved** across <Stat>2,000+</Stat> pages, against strict WCAG 2.1 AA conformance.
- **Screen-reader flows optimized end-to-end.** Landmarks, focus management, ARIA semantics, error association, and form-progress announcements continuous across the multi-step claim flow, not bolted on per-screen.
- **Tab-navigation flows optimized** as the default keyboard pattern, not as a checkbox audit at the end.

## Performance, measured in milliseconds

The user base is on five-year-old phones over flaky connections. Time-to-load and time-to-interactive get measured in milliseconds because that's the difference between a claim that completes and one that gets abandoned mid-flow.

The biggest performance work was on the navbar, with a **<Stat>~30%</Stat> average improvement** in time-to-load and time-to-interactive across the hot paths I touched. The wins came from two angles:

- **Optimizing the caching layer** so navigation transitions stopped re-fetching identical data on every screen.
- **Collapsing O(n²) logic into O(n).** Replacing nested loops with single-pass iteration that cached intermediate results, then read from cache. Significant relief on the caseworker-side pages with large data sets.

## Maintainability: pulling the sprawl back into one place

Long-running public-sector codebases accumulate the way you'd expect: sidebar logic duplicated across screens, prop drilling four or five levels deep, permissions state with no clear ownership. I led targeted detangling work alongside the accessibility pass:

- **Sidebar code consolidated into a single source**, eliminating the per-screen forks that had drifted apart over the years.
- **Modern React hooks throughout**, replacing the older class-component patterns the codebase had inherited.
- **Context state for user permissions** so any component could read the active user's permissions from a single lookup at the auth boundary instead of N redundant calls across the tree.

## The trade-offs

Public-sector frontend has constraints most product engineers never see: a regulatory surface that constrains every change, a release cadence governed by audit cycles, a legacy backend you're not rewriting, and a user base where "use a different browser" isn't an acceptable answer for anyone. The accessibility work isn't a separable workstream. It's the entire thesis of the rebuild.

---
## IRS.gov — Payments Feature (financial services)

URL: https://kevinmurphywebdev.com/portfolio/irs-payments
Role: Senior Engineer
Year: 2023–2024
Stack: ReactJS, TypeScript, Performance
Summary: Led the frontend rebuild of the tax-payment UX for IRS.gov — 880.9M annual visits supporting $4.7T in FY2023 federal tax collection. ReactJS + TypeScript, complex state across multi-step financial flows, shipped through a high-stakes release train.

IRS.gov is the largest public-sector financial UX surface in the United States. The Payments experience handles the actual collection: taxpayers funding the federal government one transaction at a time. Scale: <Stat>880.9 million</Stat> visits in FY2023, <Stat>$4.7 trillion</Stat> in tax collection supported.

I led the engineering on modernized payment features: building new flows from scratch, leading more junior and mid-level engineers on the same work, helping the team adopt TypeScript mid-project, and pushing accessibility standards into every interaction. The challenge isn't novel architecture. It's that the system has to work for everyone, every time. Every income bracket, every accessibility profile, every device, every browser the federal government still has to support.

## Accessibility: error-validation flow across the payment surface

Public-sector tax software runs into edge cases most product engineers never see. A taxpayer unable to complete a payment because of an unsurfaced validation error doesn't switch browsers; they call the IRS, or the payment doesn't go through. Every error had to be recoverable, announceable, and obviously locatable.

The error-validation pattern across the payment flow:

- **On-click validation at submit / next.** When the user attempts to advance, the page runs full validation across every field, gathering errors simultaneously rather than failing on the first one.
- **Top-of-page error summary.** A bulleted list of every error that occurred on the page, so screen-reader users hear the full scope of what needs fixing in one announcement instead of discovering errors field by field.
- **On-blur validation per field thereafter.** Once errors are surfaced, each field validates as the user leaves it, giving immediate feedback without requiring a re-submit. The cycle continues until the page is clean and the user advances.

All of this implemented against strict WCAG standards.

## Leading new features end-to-end

I led the engineering for new payment-experience features that didn't exist in the legacy system: modern mobile-friendly tax payment flows, pre-paying taxes, and other interactions taxpayers expect from any payments product in the current era. Each feature was built from scratch rather than retrofitted onto the legacy markup.

That work included leading junior and mid-level engineers building alongside me, pairing on technical direction and owning the shipping cadence end-to-end.

## TypeScript adoption mid-project

Mid-project the team pivoted from JavaScript to TypeScript. I helped lead that transition: bringing the team along on the new layer of type safety, and through the early-adoption stage where TypeScript can feel like friction before it starts catching the bugs it's meant to catch.

Beyond the language switch, I focused on maintainability through code review:

- **Adapting feedback style to the engineer.** Some teammates wanted rigorous nit-level comments, some wanted high-level direction, some wanted to pair through it. Reviews landed differently when matched to how the recipient processed feedback.
- **Reviewing for the next maintainer**, not just the immediate change. Leaving the codebase in a state the next engineer to touch it could understand without archaeology.

## The trade-offs

Engineering at this scale stops being about clever architecture. It's about staying boring on purpose, surfacing failure modes early, and writing code the next engineer can read without regret. The frontend at IRS isn't where the heavy security or PII handling lives (that's the backend's job) but at <Stat>880M</Stat> visits a year, it's the surface every taxpayer touches, and its failure modes are visible to every one of them.

---
## Federal Reserve — FedNow Onboarding (financial services)

URL: https://kevinmurphywebdev.com/portfolio/fednow
Role: Senior Engineer
Year: 2021–2023
Stack: Lightning Web Components, Salesforce
Summary: Built a new bank-onboarding application for the Federal Reserve's FedNow instant-payment system — brought a 60-day onboarding cycle down to 7 days for 1,000+ participating banks. Sole frontend on Lightning Web Components + Salesforce, co-located with the Federal Reserve's Boston team.

FedNow is the Federal Reserve's instant payment system: real-time settlement at the wholesale-banking layer. Banks have to integrate to offer their customers real-time payment capability. The onboarding process this platform replaced was paperwork-heavy and bespoke; it took <Stat>60 days</Stat> per bank. After: <Stat>7 days</Stat>.

I was the single frontend developer for most of the project, building a brand-new onboarding application from the ground up on Salesforce with Lightning Web Components. <Stat>1,000+</Stat> banks have run through the flow. Another frontend engineer joined for ~6 months in the middle of the engagement; the rest of the two-year arc, I was the only frontend developer.

## On-site embedded engagement with the Federal Reserve

The Federal Reserve's team was based in Boston. I traveled to **co-locate with them at the Deloitte Lower Manhattan office**, <Stat>4–5 trips per year over two years</Stat>. Co-location wasn't optional decoration; it's how the work actually got done in a regulated, high-security environment where most of the substantive design conversations had to happen in-person.

What that looked like, day to day:

- **Hands-on-keyboard pairing with customer engineers and product managers.** Sitting next to Federal Reserve developers and PMs, working through implementation choices in real time on the same screens. Apex backend developers on their side were learning frontend ahead of the contract handoff, so part of the in-person work was informally mentoring them as they ramped on the codebase I was leading.
- **Discovery and requirements gathering during planning sessions.** Every co-location trip included planning sessions where we mapped the next several months of work. The roadmap that came out of those sessions wasn't dictated to either side; it was negotiated in person, against the actual constraints both teams were carrying.
- **Regulated-environment delivery.** High-security setting throughout. Decisions about what shipped, what got deferred, and what got reshaped to fit the security envelope happened on the customer's terms, in their building, with their people in the room.

## Why the customer asked for me by name

The contract between Deloitte and the Federal Reserve was extended multiple times across the two-year engagement. **The customer asked for me by name each time** as their main frontend developer (and for most of the project, their only one). That kind of long-term customer relationship is the thing applied-AI roles ask for hardest, and it's not built on any single pull request. It's built on showing up in person, being the person the customer can talk to about tradeoffs without translation, and shipping what was agreed to in the planning sessions.

These skills (on-site embedded engagement, hands-on-keyboard pairing with customer engineers and PMs, discovery and requirements gathering, regulated-environment delivery, long-term customer relationships that lead to contract extensions) are the same skills product-engineer roles at AI-applied teams describe.

## Picking up Lightning Web Components in two weeks

I came onto the project with zero Salesforce or LWC experience. <Stat>Two weeks</Stat> later I was pushing to production. LWC's ergonomics are closer to vanilla HTML, CSS, and JavaScript than to React (the framework leans on Web Component primitives) so I leaned hard on foundational JavaScript and ramped fast.

I earned the Salesforce JavaScript Developer certification during this period (since expired). A useful forcing function early on, and a signal that the JavaScript foundation transferred cleanly into the Salesforce ecosystem.

## A reusable component library, built from scratch

Salesforce's out-of-the-box LWC base components couldn't meet the design team's spec. They're generic by design: fine for typical Salesforce admin surfaces, not fine for the highly custom flows FedNow needed. I built a reusable component library covering the visual and behavioral surface the design system asked for, owning each primitive end-to-end:

- **Advanced data tables** with multiple layers of inline error validation and the kind of cell-level interaction the stock LWC tables don't support.
- **A step-by-step form wizard** that drove the digital onboarding forms: the surface where banks supply the regulatory information the Federal Reserve needs to enable their instant-payment integration.
- **Branching flow logic** inside the wizard. Different banks see different screens depending on their answers, so each branch had to stay coherent and recoverable as the user moved through.
- **An onboarding progress page** spanning days of in-flight bank work: a dynamic vertical progress bar with collapsible sections and per-step completion states, surfacing exactly which step the bank was on, what forms and information were still outstanding, and what had already been submitted. The custom CSS to land that layout wasn't trivial.

By the end, almost every interactive surface in the onboarding flow was composed from this library.

## The trade-offs

LWC has different ergonomics than React, and the integration surface to Salesforce's data model isn't optional. The job was to make the onboarding feel like a deliberately designed product on top of a platform that wants to dictate everything from form layout to validation behavior. Where the platform fought us, I built around it. Where it gave leverage, I used it. The customer outcome (onboarding that went from 60 days to 7 days for 1,000+ banks) got built in person, in regulated space, against a roadmap negotiated face-to-face every quarter.

---
## ASU Mobile App

URL: https://kevinmurphywebdev.com/portfolio/asu-mobile
Role: Full-Stack Engineer · full-time ASU staff
Year: 2018–2021
Stack: React Native, AWS
Summary: Led the React Native frontend for the official ASU Mobile App's v2 release (and finished v1) as a full-time ASU staff engineer, reaching 58,000+ weekly student users on iOS and Android for the largest U.S. public-research university by enrollment. Also a frequent backend contributor across the AWS stack: Node.js Lambda, API Gateway (WebSocket, HTTP, REST), DynamoDB, AppSync, Cognito, S3, SNS, and CloudWatch, provisioned with Terraform and Amplify. Built geolocation features, push-notification management, and offline-capable instant chat with Apollo Client online/offline caching.

ASU's mobile app is the campus-front-door for one of the largest research universities in the country. <Stat>58,000+</Stat> weekly active users hit it for class schedules, campus events, geo-located activities, instant chat with student services, and roughly two dozen other surfaces students don't realize aren't separate apps.

I led development across multiple feature areas in the React Native codebase, with AWS handling the backend service composition. The work spanned native module integration (geo-location, push, local notifications) through to the conversational features that fed into Sunny, the chatbot covered separately.

## A campus Swiss-Army knife

Most of the surfaces that look like separate apps to a student actually live in this one. Highlights I built or contributed to:

- **Mobile ticketing for home football games**, replacing the legacy paper / will-call flow at Sun Devil Stadium gates.
- **Free student event ticketing** for non-athletic events across campus.
- **Custom schedule management.** Students built, edited, and viewed their personal academic calendar inside the app.
- **An in-app social feed with instant messaging** for campus-community communication. The messaging layer leaned heavily on AppSync.
- **In-app integration with Sunny**, the university chatbot. Students conversed with student-services automation without leaving the app.
- **A customizable home screen with drag-and-drop widgets**, so each student composed their own dashboard of frequently-used features.

## Geo-location as a first-class platform feature

Some of the more distinctive engineering was around location:

- **Geo-location events.** Surfacing what was happening near the user, when, and how to engage with it.
- **Zone-bound bonuses, add-ons, and freebies** for students physically in specific geo-zones at specific times.
- **A gamified event system** that awarded points for attending events and completing in-zone tasks: a layer on top of the same geo-engine and event catalog.

## Offline-first on flaky campus Wi-Fi

Mobile at university scale is its own engineering discipline. Devices range from current iPhones to four-year-old Androids on flaky campus Wi-Fi. The codebase has to absorb academic-calendar-driven traffic spikes (move-in week, finals, registration windows) without falling over.

The backend leaned on **AWS AppSync** for offline data caching: requests made without connectivity were queued locally and flushed when Wi-Fi came back. From the student's perspective, the app stayed usable through dead zones. The request to RSVP to an event, mark a task complete, or update a schedule succeeded the moment connectivity returned. The instant-messaging layer of the social feed sat on top of the same AppSync stack.

During the messaging build I tracked down a mission-critical bug in the Apollo client layered on top of AppSync: a legitimate cache-behavior issue in the framework that had stumped the rest of the office. The fix was a few lines; the path to it wasn't.

Most of the work here is unglamorous: caching, offline-first patterns, graceful degradation, deferred sync. That's where the user impact actually lives.

## Seven days to ship a COVID symptom-check survey

When COVID broke out in early 2020, ASU's president Michael Crow reached out directly to our office: ship a campus-wide symptom check-in inside the app, in <Stat>seven days</Stat>. Students would check in daily if symptomatic; the geo-location layer correlated those check-ins against who else had been physically near them, so contact-tracing follow-ups could happen faster than manual reporting allowed.

We shipped on time.

The seven-day window was only possible because the platform investments were already there: the geo-engine, the offline cache, the AppSync stack. Foundations matter most when the timeline doesn't permit any.

## The trade-offs

Native modules across iOS and Android each have their own quirks, and a feature that ships clean on one platform will have a five-line workaround on the other. Most of the engineering value isn't in the headline feature; it's in the failure modes you anticipate so the app doesn't break for the student trying to register for class on shaky campus Wi-Fi.

---
## Sunny Chatbot Dashboard

URL: https://kevinmurphywebdev.com/portfolio/sunny-chatbot
Role: Full-Stack Engineer · full-time ASU staff
Year: 2018–2021
Stack: ReactJS, AWS
Summary: Built ASU's SMS, web, and mobile-app chatbot platform in-house with a five-person office — replacing a $1M/year outsourced vendor contract. ReactJS dashboard + AWS NLP backend.

Sunny is ASU's chatbot. The university was spending <Stat>$1,000,000 a year</Stat> to outsource a chatbot to an external vendor; we built it in-house with a five-person office. The vendor contract (and that recurring spend) went away.

I worked on both halves of Sunny: the conversational interface students hit across SMS, in-app chat, and the university website, and the React + AWS dashboard staff used to operate it. The conversational engine meant integrating with NLP backends, handling fallback flows for ambiguous queries, and structuring the conversation tree so it could grow as more student-services categories were added.

## A no-code dashboard for non-technical staff

The administrators who actually run student services are not engineers. The dashboard had to make Sunny operable by them without a single line of code or a developer in the loop:

- **A no-code notification builder** that let non-technical staff compose scheduled outbound notifications to students and staff: pick the audience, write the message, schedule the delivery time, ship.
- **Operational visibility** so the same staff could see where Sunny was succeeding and where it was failing without learning a query language, then refine the conversation tree themselves.

## Three surfaces, one chatbot

Sunny met students wherever they already were:

- **SMS.** Students texted in and got the same conversational engine.
- **In-app chat** inside the ASU mobile app, built in-house.
- **The university website.** Sunny was the engine; the embedded chat interface on the site was built by a separate team.

Same conversation logic across all three; the surfaces were just transports.

## The trade-offs

Building a conversational system in-house at a five-person office instead of outsourcing means owning every failure mode yourself. When a student gets a bad answer at 2 a.m. before finals, that's on the team. The trade is unambiguous: the institution keeps the savings, the team keeps the operational lens an outsourced vendor would never have given them, and the system evolves at the speed of the people running it instead of the speed of a vendor's release cycle.

---
## ASU Football Scoreboard Games

URL: https://kevinmurphywebdev.com/portfolio/asu-scoreboard
Role: Full-Stack Engineer · full-time ASU staff
Year: 2018–2021
Stack: Angular, AWS, IoT
Summary: Interactive in-game scoreboard experiences at Sun Devil Stadium — driven by stadium microphones and video feeds piped through an AWS backend, with the games rendered on the scoreboard itself for 50,000+ fans on home football Saturdays. Angular frontend.

Sun Devil Stadium holds <Stat>50,000+</Stat> fans on football Saturdays. The scoreboard system runs interactive games during home games, driven by stadium microphones and video feeds piped through an AWS backend, with the games rendered on the scoreboard itself.

I worked on the Angular frontend for some of the games. The reason this one is here is the experience, not the scope.

## A couple of the games we built

- **A celebrity face-match game.** Fans in the stadium got matched in real time against their celebrity lookalikes from the video feed, with the match percentage displayed on the scoreboard.
- **A noise game.** We'd show up at home games early and set up microphones on the east, north, west, and south sides of the stadium. The system read real-time sound levels from each side and put the loudest one up on the board.

## What made it memorable

Getting to home games early to help set up the rigs, all-access passes for the press box and the sidelines, the kind of on-field involvement most engineering work doesn't let you have. Simple responsibilities, real proximity to the school. Memorable corner of the job.

---
## Wellness Hub Recovery (healthcare)

URL: https://kevinmurphywebdev.com/portfolio/wellness-hub
Role: Full-Stack Engineer
Year: 2020
Stack: React, React Native, AWS
Summary: Built the MVP for a recovery wellness platform end-to-end — clinician dashboard, client portal, native mobile wellness program, EMR intake forms, and insurance-billing digital signatures, on top of an AWS backend (DynamoDB, AppSync, Lambda, API Gateway) I designed and deployed myself. Solo freelance, shipped during the pandemic remote-care pivot.

Wellness Hub Recovery is a recovery-focused wellness platform. I built the MVP during 2020, the year remote-first care delivery became urgent for everyone in this space. Clients needed to engage with the practice from home; the practice needed to maintain continuity with people whose lives had been disrupted.

I shipped the MVP solo end-to-end. The web (React) and native mobile (React Native) clients sat on an AWS backend I designed and deployed myself: DynamoDB for storage, AppSync for the realtime data layer, Lambda + API Gateway for the rest. The constraint that shaped every screen was respect for the user. Recovery clients are not a market to be optimized; they're people in a vulnerable phase, and the software has to be careful with attention, with notifications, with the language of every interaction.

## Solo end-to-end on a one-person team

A one-person team means owning every part of the engagement, not just the engineering:

- **Discovery and requirements gathering directly with the founder.** Translating "we need a recovery wellness platform" into a concrete scope across two clients, an EMR layer, and an AWS backend. No PM, no spec doc handed down. The requirements came out of working sessions with the customer.
- **Surfacing tradeoffs back to the customer in plain language.** When a request would blow the timeline or the budget, the conversation about which version was actually worth shipping was mine to drive.
- **Owning the architecture decisions that don't get revisited.** DynamoDB vs. Postgres, AppSync vs. polling, where the boundary between web and mobile lived, what the EMR data model would have to support a year out.

This is the same shape product-engineer roles at AI-applied teams ask for: sit close to a customer's problem, do the discovery work, decide the right tool for the job, ship something that holds up, alone if you have to.

## A wellness program that spans mobile and web

Clients did their wellness check-ins on the native mobile app: structured prompts about how they were doing, what they were experiencing, what was helping. That data flowed into the provider portal on the web app, where clinicians saw each client's history rendered as graphs and charts. A clinician could open a client's record and see the trajectory at a glance instead of paging through individual entries.

The two surfaces are halves of the same thing: the mobile app is what clients touched, the web app is what the people supporting them touched, and the data layer connecting them is what made the platform useful as a recovery tool rather than a logging app.

## EMR intake: robust digital forms

Onboarding a recovery client into the system required a substantial amount of structured data. I built the EMR intake layer with:

- **Many distinct intake questions and data points** across the onboarding flow, structured so the form could grow as the practice added new questions without a rebuild.
- **Advanced form behavior** (conditional fields, validation, progressive disclosure, save-and-resume). The affordances a long intake needs to feel manageable rather than punishing for a vulnerable client.

## Digital signatures for insurance billing

Insurance companies require signed documentation for billing. I built a digital signature feature into the platform so the practice could capture client signatures during onboarding without printing, scanning, or routing paper.

The integration is the part that matters: signed artifacts had to attach to the right place in the client record with the right metadata so billing actually cleared. The signature itself is a solved problem; the place it lives in the EMR is the work that makes it useful.

## The trade-offs

Building a clinical-adjacent platform end-to-end as a solo freelance engineer means early architecture decisions stick, both on the frontend surfaces and on the AWS infrastructure underneath. The startup ultimately didn't continue past the MVP, but the platform shipped (clinician portal, client portal, native mobile, EMR intake, insurance signatures, the AWS backend tying them together) at the scope and quality the practice would have needed if they had continued. The job was to ship something honest enough to operate on, fast enough to matter while remote-first care was still finding its shape.

---
## Healing Foundations Counseling (healthcare)

URL: https://kevinmurphywebdev.com/portfolio/healing-foundations
Role: Full-Stack Engineer
Year: 2021
Stack: React Native, AWS
Summary: Native mobile app for a counseling practice — wellness check-ins, a provider portal with charts and graphs, and a news feed for the wellness journey. Built solo end-to-end on an AWS backend (DynamoDB, AppSync, Lambda, API Gateway) I designed and deployed myself.

Healing Foundations Counseling is a counseling practice. I built their mobile app: a React Native client tied to an AWS backend I designed and deployed end-to-end (DynamoDB, AppSync, Lambda, API Gateway).

I shipped the project solo as a freelance engineer. The app supported clients and providers as two role-based views over the same backend, so the same codebase served both sides of the relationship without forcing either to leave the app.

## One-person team, end-to-end customer ownership

There was no PM, no design partner, no spec doc. Just a counseling practice that wanted a wellness app and a working relationship I had to build with them:

- **Requirements gathering directly with the practice.** Translating "we want clients to do wellness check-ins and providers to see them" into the actual data model, the actual two-role surface, the actual feed of supporting content.
- **Communication on tradeoffs.** When scope expanded, the conversation about what we shipped first vs. later was mine to lead. The customer doesn't always know which features are cheap and which are expensive; that's the freelance engineer's job to surface honestly.
- **Architecture decisions on a long timeline.** Picking AWS services that the practice could afford to operate after I was gone, structuring the codebase so the next engineer could read it.

These are the same skills product-engineer roles at AI-applied teams ask for: sit close to a real customer, do the discovery work yourself, ship something honest, and leave it in a state someone else can extend.

## Wellness check-ins, with the provider seeing it back

Clients did wellness check-ins on the app: structured prompts about how they were doing, what they were experiencing, what was helping. That data fed the provider portal, where clinicians saw each client's history rendered as graphs and charts. A provider could open a client's record and read the trajectory at a glance instead of paging through individual entries.

## A feed for the wellness journey

Recovery and ongoing care isn't just check-ins. It's the surrounding content that helps people make sense of what they're doing. The app's feed surfaced:

- **Curated news articles** relevant to clients' wellness contexts.
- **In-app suggestions** for actions a client could take to support their journey.
- **Adjunctive therapy facilitation features.** Surfaces that pointed clients toward complementary care beyond the primary provider relationship.

## The handoff

When Deloitte's offer came in, I had to wind down freelance work. The Healing Foundations engagement wasn't done. So a piece of the project became finding the engineer who would finish it: interviewing candidates, picking the right one, onboarding them through the architecture and the unfinished work, then transitioning ownership cleanly so they could ship the rest.

That kind of handoff (leaving a codebase in a state another engineer can pick up without archaeology) is its own piece of work. The job wasn't just to build the app; it was to leave the project in a place where someone else could finish what I started.

---
## Nation Analytics

URL: https://kevinmurphywebdev.com/portfolio/nation-analytics
Role: Full-Stack Engineer
Year: 2016
Stack: WordPress
Summary: First public-facing web presence for a government-spend-tracking consultancy — a credibility marketing surface describing the business.

Nation Analytics is a government-spend-tracking consultancy. They needed a first website to establish public presence and verify the business as legitimate to potential clients. I built it.

The project was small and bounded: a WordPress brochure site describing the business, frontend-only, no custom backend. What made it interesting wasn't the engineering. It was that this was one of my first freelance engagements and the first payment I ever received for web development work. It also taught me the part of the job that engineers most often skip and product-engineer roles at AI-applied teams ask for hardest: communicating with a real customer, gathering requirements, and shipping when the customer goes quiet.

## A small project with a long client-requirements arc

Most of the work wasn't the WordPress build. It was the client work that surrounds any small-business website:

- **Helping the client choose a domain name** based on what was actually available to buy. They hadn't worked through the inventory.
- **Purchasing the domain** on their behalf and getting it pointed at the site.
- **Surfacing requirements.** What the site should say, who it was speaking to, what language should represent the business publicly. Most of this was extracted from a client who wasn't very responsive, which made the requirements work its own kind of work.
- **WordPress development.** Basic CSS, web design, technical implementation across the standard pages a brochure site needs.

The technical build was the smaller piece. The communications and requirements work (getting unblocked when the client went quiet, asking the right questions to surface what they actually wanted, making decisions on their behalf when responses didn't come) was the larger one.

## Why it mattered for the work I want next

This was an early project, but it was a pivotal one. First freelance engagement that paid. First time taking a project from "client wants a website" to "site is live at their domain name." First time working through the messy, non-engineering parts of being the only person responsible for a software outcome: purchasing the domain, surfacing requirements, making decisions when the client went quiet.

Those reps are exactly the muscles a product engineer at an AI-applied team has to exercise daily. Sit close to a customer, drive the requirements conversation, make calls when the customer can't, and ship something the customer can actually use. Everything that came after (the rest of my freelance work, FedNow's discovery sessions with the Federal Reserve, the discovery work on the open-source agent projects) used these reps.

---
## Folkways Digital Archive

URL: https://kevinmurphywebdev.com/portfolio/smithsonian-folkways
Role: Web Development Intern
Year: 2015
Stack: WordPress
Summary: Updated musician profiles and supported general maintenance on folkways.si.edu — the Smithsonian Folkways Recordings digital archive, maintained by the Center for Folklife and Cultural Heritage.

folkways.si.edu is the Smithsonian Folkways digital archive, the online presence for one of the country's most important folk music recording catalogs. I worked on it as an internship at the Smithsonian Institution's Center for Folklife and Cultural Heritage in Washington, D.C., a place I'd grown up just outside of, in Northern Virginia. It was the summer before I knew I wanted to be a software engineer.

I came in with a BA in Anthropology and no career direction. My dad had built a successful software business in government contracts, so I'd been close to software my whole life without planning to do it myself. The internship listing wanted web-development familiarity; I had enough adjacent exposure to apply.

## What the work was

The site had hundreds of musician profile pages for the artists Folkways had recorded over the decades. My job was to update them and keep the site running:

- **Musician profile updates.** Adding new entries, refreshing existing ones, keeping artist information current.
- **General WordPress maintenance.** The low-stakes content and admin work an internship gets handed because it teaches the surface area without breaking anything important.

It was my introduction to WordPress, and through WordPress, my introduction to web development as something a person could actually do for a living.

## The room I was working in

The work environment was a folk music recording studio and digital archive. I held the original vinyl pressing (the very first copy ever pressed) of "This Land Is Your Land" by Woody Guthrie. That was the building I was learning WordPress in.

## How a BA in Anthropology turned into this

Mid-internship, my mentor told me to build a personal website. I did. There was nothing in the portfolio yet except the internship, but the act of building my own site (picking a domain, designing the layout, writing the copy) was the moment something clicked. Web development was a thing I could keep doing, and a thing I wanted to.

That first portfolio site has been rebuilt many times since: different stacks, different design eras, eventually adding case studies and a blog. The portfolio you're looking at is several generations downstream of the one my mentor told me to build that summer. The line from that internship to the rest of my career is the straightest line in my professional history.

This isn't a project with bells and whistles. It's the most foundational one on this whole portfolio.

---
## Resume Summary

URL: https://kevinmurphywebdev.com/resume

Full-stack product engineer with professional software experience since 2015. Frontend at federal scale in React and TypeScript, backend in Node.js and NestJS on AWS (Fargate, RDS PostgreSQL, Lambda, API Gateway, DynamoDB, AppSync, CDK), and native mobile in React Native. Takes products from data model to interaction: API design, infrastructure as code, and the UI users actually touch. Ships applied AI — agent orchestration, retrieval, and evals — in open-source, and codes AI-augmented daily on Claude Code with a spec-driven workflow. Leads teams and mentors engineers; a background in cultural anthropology sharpens the read on the gap between what users actually do and what the spec describes.

Education: Full-Stack Engineering Bootcamp (Thinkful, Jan – Jul 2018); B.A. Anthropology (University of Vermont, 2010 – 2014)
Certifications: AWS Developer Associate; AWS Solutions Architect Associate

---
