All posts

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.

  1. Calendar

    Find free windows

    Google Calendar free/busy, inverted into 5+ day gaps.

  2. Fan out

    Search in parallel

    Places, restaurants, flights and hotels at once; one failure doesn't sink the turn.

  3. Model

    Stream a UI tree

    Gemini in JSON mode, cheapest model first, falling back on overload.

  4. Parse

    Cut out each card

    A streaming parser emits every child the moment its braces close.

  5. Render

    Cards appear one by one

    Sent over Server-Sent Events into a closed component registry.

From calendar to cards, streamed

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

    This site

    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

    Holy Mary Chain

    A Cosmos SDK blockchain that recorded who visited a shared space.

    Cosmos SDK · CometBFT · CosmJS

  • 2025

    Code Salmon

    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