How I build software
The languages, frameworks and systems I work with, shown through the hard parts of real projects: streaming generative UI, payments, real-time presence, constrained LLM output and accessibility.
I build full-stack software end to end: interfaces people enjoy using, the APIs and data behind them, and the pipelines that ship them. TypeScript is my home language, and Python is where I do most of my AI work. My current role is in design engineering, working on design systems, frontend libraries and accessibility, which means I tend to care as much about the last pixel as the first query.
This is a map of that work, told through the parts that were hardest to get right.
Languages
- TypeScript
- Python
- JavaScript
- SQL
- HTML & CSS
Frontend
- React
- Next.js
- Astro
- Tailwind CSS
- Design systems
- Accessibility
Backend
- Node.js & Express
- FastAPI
- Zod
Data
- PostgreSQL
- Supabase
- Prisma
- Redis
AI
- LLM integration (OpenAI, Gemini)
- Generative UI
Cloud & delivery
- AWS (S3, CloudFront, IAM)
- GitHub Actions
- Docker
- Stripe
Testing
- Playwright
- axe
- Vitest
- Testing Library
Generative UI that streams in
An AI travel planner I built reads your calendar, finds the free stretches in the next three months, and proposes trips: flights, hotels, restaurants and things to do, laid out as cards you can add to a plan. For a group, it reads everyone’s calendar and finds the windows that work for the most people.
The interface isn’t predetermined per response. Instead, I give the model a constrained UI grammar,
a closed set of components (Column, Row, Card, Text, Button), and it chooses how to
compose them. The model proposes; deterministic code validates and renders. The problem is time: a
full response takes several seconds, and waiting for the whole JSON document before showing
anything feels broken.
Calendar
Find free windows
Google Calendar free/busy, inverted into 5+ day gaps.
Fan out
Search in parallel
Places, restaurants, flights and hotels at once; one failure doesn't sink the turn.
Model
Stream a UI tree
Gemini in JSON mode, cheapest model first, falling back on overload.
Parse
Cut out each card
A streaming parser emits every child the moment its braces close.
Render
Cards appear one by one
Sent over Server-Sent Events into a closed component registry.
So the server parses the model’s output while it’s still arriving. A small stateful parser counts braces, tracks whether it’s inside a string (and whether a quote is escaped), and the moment a top-level child object closes, hands it on:
def _extract_object(self, start: int) -> tuple[dict | None, int]:
depth, in_string, escape_next = 0, False, False
pos = start
while pos < len(self._buf):
c = self._buf[pos]
if escape_next:
escape_next = False
elif c == "\\" and in_string:
escape_next = True
elif c == '"':
in_string = not in_string
elif not in_string:
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return json.loads(self._buf[start : pos + 1]), pos + 1
pos += 1
return None, start # not complete yet: wait for more tokens
The model’s SDK streams synchronously, so calling it directly would block the event loop that
serves every other request. It runs on a worker thread instead and passes each finished card to the
async server through a queue with loop.call_soon_threadsafe. FastAPI sends the cards to the
browser as Server-Sent Events rather than over a WebSocket: the stream only flows one way, server to
client, and SSE stays plain HTTP, so it passes through ordinary proxies and the browser reads it
with a streaming fetch.
On the client, the renderer is a closed registry: a switch over the known component types, where
anything unknown renders nothing, and text is HTML-escaped before any formatting is applied. The
model decides the composition; it can’t inject markup.
The group planner is plain algorithms, no model needed. It classifies each member’s events as hard or soft commitments, builds a day-by-member availability grid, slides 5, 7 and 10-day windows across it, and scores each from 0 to 100 (−10 for each member-day with a hard conflict, −3 for a soft one, normalised against the worst case). The best window per start date wins, and the top five come back as a heatmap.
A platform with payments and real-time presence
The previous version of this site was a full-stack platform I built as a TypeScript monorepo: a Next.js app, an Express API, a background worker, and a shared package of types and Zod schemas that both sides import. The same schema that validates an HTTP body validates a WebSocket message, so client and server can’t drift apart.
Payments used Stripe Checkout. A donation is written as PENDING before the customer is sent
to Stripe, and only a verified webhook moves it on. Verification needs the raw request body, so the
webhook route is mounted before the JSON parser, and every event is stored in an audit table:
app.use("/webhooks/stripe", stripeWebhookRouter); // raw body, before JSON parsing
app.use(express.json({ limit: "10mb" }));
// in the webhook router
event = stripe.webhooks.constructEvent(req.body, signature, env.STRIPE_WEBHOOK_SECRET);
Rate limiting was a sliding window in Redis, shared by the HTTP routes and the WebSocket
server. Pruning, counting and recording the request run as one MULTI/EXEC transaction, so
concurrent requests each see a distinct count and two can’t both slip in as the last one allowed.
Every attempt is recorded, rejected ones included, so a client that keeps hammering stays limited
until it backs off:
const multi = redis.multi();
multi.zremrangebyscore(key, "-inf", windowStart); // forget requests outside the window
multi.zcard(key); // count what's left
multi.zadd(key, now, `${now}-${Math.random()}`); // record this one
multi.expire(key, windowSeconds);
const results = await multi.exec();
const allowed = results[1][1] < limit; // the count taken inside the transaction
Real-time presence in the shared space ran on Supabase Realtime: one channel per space,
presence tracked as visitors moved, new notes arriving through database change events,
de-duplicated by id. The browser talked to the database directly, so authorization lived in the
database itself: row-level security policies mean the rules still apply even if application code
asks for the wrong rows, and a SECURITY DEFINER trigger (with a pinned search_path) creates a
profile for each new user.
Entering the space also signed a transaction to Holy Mary Chain, the Cosmos SDK blockchain I ran. That wasn’t necessary infrastructure for the site; it was an experiment in making visits independently verifiable outside the application’s own database. It used a wallet generated in the browser with CosmJS and a custom protobuf registry, and the space carried on normally whenever the chain was offline.
Around all of it: environment variables validated at boot, so a misconfigured deploy fails
immediately instead of at the first payment; a health endpoint that reports 503 when Postgres or
Redis is down; graceful shutdown; and video uploads that go straight to object storage through
presigned URLs, so large files never pass through the application server.
Constraining model-generated code
For a design-system tool, I built an assistant that turns a plain-language description into working component code, with a live preview. Two things go wrong in practice: the model returns something that isn’t valid JSON, or it invents components that don’t exist.
The server parses in layers: strict JSON.parse, then pulling the fields out with a pattern, then
extracting a fenced code block, then falling back to the raw text, so a slightly malformed answer
still produces something to look at. Before rendering, the client checks every component the code
uses against the real library’s exports and refuses to preview anything else:
const used = extractComponentNames(code);
const available = Object.keys(DesignSystem);
const missing = used.filter((name) => !available.includes(name));
if (missing.length > 0) {
// show which components don't exist, instead of rendering anything
}
const previewScope = { React, ...DesignSystem, ...handlerScope };
The preview evaluates the code with only those names provided, and event handlers are stubbed so it renders without crashing. That’s a constraint, not a security boundary: the code still runs in the page’s own JavaScript context, so it guards against honest mistakes, not hostile code. Real isolation would mean running the preview in a sandboxed iframe on a separate origin. The API key, meanwhile, never leaves the server.
Accessibility and testing as code
Accessibility isn’t a checklist I run at the end. In a set of reference apps I wrote focus traps for modals, live regions that announce changes, form errors that move focus to a summary, and toggle buttons that expose their state:
// keep Tab inside the open dialog
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
On this site, machines enforce it, along with everything else. The testing is layered:
- Unit tests (Vitest) pin down the logic behind the drawings: the summit ridge must put the tallest peak exactly at the top margin and keep every summit to scale, and the generated maps must come out identical on every build.
- End-to-end tests (Playwright) run against a fresh production build, never the dev server, in a desktop browser and on a phone-sized screen. They check real behaviour: a missing page answers with a true 404 status, and every share image and icon is served with the right type.
- Automated accessibility tests run axe against every page, targeting the testable WCAG 2.2 AA criteria, in light and dark mode. Automation catches part of WCAG, not all of it; the rest takes a person with a keyboard and a screen reader.
- Strict TypeScript, linting and formatting run alongside them.
GitHub Actions runs the whole suite on every push, and the deploy job only starts once it passes. How this site is built has the rest.
Selected work
2026
AI travel planner
Calendar-aware trip planning with streamed generative UI and a group scheduling engine.
Python · FastAPI · Gemini · React · Supabase
2026
Static, accessible, about a dollar a month, deployed by CI with short-lived credentials.
Astro · TypeScript · AWS · GitHub Actions
2026
Creator platform
Payments, real-time presence, AI chat, video uploads and on-chain attestations.
Next.js · Express · Postgres · Redis · Stripe
2026
A Cosmos SDK blockchain that recorded who visited a shared space.
Cosmos SDK · CometBFT · CosmJS
2025
An open-source CLI that detects API drift by reading code as a syntax tree.
TypeScript · ts-morph · Node.js
2025
Component assistant
Plain language in, design-system code out, with a constrained live preview.
React · TypeScript · Express · OpenAI