BlockedInRead story (opens in a new tab)2025–2026
Experiments
Things I built to find out whether they would work. Smaller than the case studies, and each one honest about where it stops.
Hastin
A Sanskrit treatise on elephant medicine, turned into a corpus you can search and question.
The Hastyāyurveda is one of the oldest surviving works on veterinary medicine, and like most historical scientific manuscripts it exists as page scans — which means its contents are invisible to search engines, research databases, and anything that reads. Hastin is a five-stage pipeline that fixes that: render the pages, OCR them, detect blocks, transliterate, translate. Everything lands in Postgres, and a reader sits on top with the scan always in the right-hand column.
The search decision is the one worth recording. Postgres full-text search does not tokenise Devanagari usefully, and the romanised layer is full of diacritics that FTS treats as word separators — so the standard tool is the wrong tool. Search is pg_trgm and ILIKE instead, over three GIN trigram indexes, one per script. One operator gets you sub-linear substring search across Devanagari, romanised and English at once.
Translation is a 200-line domain prompt — block taxonomy, conventions for verse numbering and manuscript sigla, and a glossary of Ayurvedic and elephant-specific terms — marked cache_control: ephemeral. Blocks are translated sequentially on purpose: firing them in parallel against a cold cache buys N cache writes instead of one write and N−1 reads. The script logs token usage per block and warns if cache reads come back at zero, because a silently invalidated prompt costs real money and looks exactly like a working one.
The assistant has a hard contract: cite a block or say you cannot answer. Every claim comes back as a markdown link whose target is a block UUID, so clicking a citation scrolls to that passage — or routes to the right page and highlights it there. The answer, the source block, and the original scan are one click apart, which is the only version of this a scholar would trust.
Where it stopsBuilt in about 36 hours, and it shows in the seams rather than the ideas: the PDF download route still has a laptop path hardcoded in it, and the corpus export assumes a single document. Neither is hard to fix — they are just the parts nobody needed working at the time.
- Python
- PostgreSQL
- Next.js
- Claude
- Sarvam OCR
ChangeOfLogs
Reads a date range of commits and writes the changelog — then gives you somewhere to put it.
Sign in with GitHub, pick a repository and a date range, and it drafts a changelog from the commits in between. The premise is that almost nobody writes changelogs, and the reason is not laziness — it is that translating fix: null check in reducer into “we fixed a crash when saving drafts” is tedious in a way that never feels like the most important thing that day.
Which is also the product insight, and it drives the prompt: a changelog is a user-facing artefact and commit messages are not. So the model gets a precomputed summary first — commit count, contributors, issue numbers pulled out of the messages — then the commits with per-file line counts, then an exact section skeleton and an instruction to skip anything that reads like wip. It is engineered for a stable shape rather than good writing, because a changelog that comes out differently formatted every time is one you have to edit anyway.
The half I would actually defend is distribution. A generated changelog that lives in a dashboard has not solved anything, so each one has a public/private toggle, a hosted page, and a one-line script embed. The widget reads document.currentScript to work out its own origin, fetches the public HTML, pulls out the content node, and appends its own scoped stylesheet — no dependencies, no build step, and nothing for the host page to configure.
Context enrichment sits behind its own try/catch on purpose: repo description, detected technologies and merged PRs get folded into the prompt when the token allows it, and when that call fails the generation degrades to commits-only rather than erroring. It is the difference between a feature and a dependency.
Where it stopsThe root README describes a CLI and an Express server that were never committed, so those npm scripts point at files that do not exist. The Next.js app is the part that works. There are also two forks of the repository analyser living side by side — one clones with simple-git, one goes through the GitHub API because you cannot clone from a serverless function.
- Next.js
- NextAuth
- Supabase
- OpenAI
- Octokit
Claude Bingo
Bingo, played on the words Claude Code shows you while it is thinking.
Twenty-five squares drawn from the loading verbs — Discombobulating, Caramelizing, Lollygagging — and you daub one each time you catch it in the spinner. It is a joke that took a day, but it is also a generator: give it a title and a number and it lays out that many unique cards, two per row, on letter paper.
Which is where the actual engineering is. Browsers strip background colours out of print by default, so every coloured header and free square carries printColorAdjust: exact inline — without it the whole card prints white and the bug is invisible until someone wastes paper on it. Pagination is break-after: page on every fourth card, under a @page rule that pins letter portrait.
There are two type scales, deliberately. The screen scale is in rem and steps down by word length; the print scale is the same ladder in points, because a fifty-point cell has to hold “Whatchamacalliting” and rem does not mean anything predictable to a print engine. The print layer is also a <table> where the screen is a CSS grid — tables are what print engines paginate reliably, and this is one of the few places where the older element is simply the correct one.
The one interaction detail worth keeping: daubing draws a translucent highlighter ring on a spring curve, not a checkmark. It costs a line of CSS and it is the entire difference between a form and a bingo card.
Where it stopsOne sitting, and there is a vestigial /print route from an earlier draft that has since drifted — it pulls the full 180-verb list and a different palette, while the live page inlines its own shorter one. Two forks of the same idea; the main route is the finished one.
- Next.js
- React
- Tailwind
- print CSS
240plus
Write past the character limit in the X composer, and post it as an image instead.
A Chrome extension that puts a button in the X compose toolbar. Type the long thought as you normally would, press it, and the text is rendered to a clean card and swapped into the post in place of itself — rather than the usual routine of writing it in Notes and screenshotting that.
Everything interesting about it comes from not controlling the page. To attach the image it builds a real File, wraps it in a DataTransfer to synthesise a FileList, assigns that to X’s own file input and dispatches change — so their uploader picks it up as though you had chosen a file from disk. No API, no internal call, just the same events a person would have produced.
Clearing the box needed the same kind of answer. The composer is a controlled input, so setting textContent does nothing that survives the next render; it dispatches one synthetic Backspace per character instead. And the button itself is re-checked on an interval rather than observed, because the composer unmounts and remounts underneath you often enough that a one-second poll is both simpler and more reliable than trying to be clever about it.
Where it stopsPagination is drawn but not wired: the code computes lines per image and renders an “N/M” counter, then inserts only the first canvas — so genuinely long text overflows one card instead of becoming a thread. It never went to the Web Store either. The shipped version of this idea is Tweet as Image, which renders somebody else’s tweet rather than your own draft.
- Chrome MV3
- vanilla JS
- Canvas 2D
LinkedIn profile agent
Reads a profile by describing what it sees, rather than by matching selectors.
Built on Stagehand, which lets you drive a browser with instructions instead of selectors: ask what sections a page has, then ask for the education history as a Zod schema and get back validated objects. The appeal is obvious the first time a class name changes and nothing breaks.
The split is what makes it work, and it is the part I would argue for generally. The model handles semantics — what is this region, what does it say — and raw page.evaluate handles mechanics. Expanding every “show more” is a selector cascade that falls back through four strategies and clicks whatever it finds, because models are unreliable at clicking things and selectors are hopeless at understanding them. Putting each on the side of the line it is good at is most of the design.
It also keeps a person in the loop deliberately: it waits two minutes for you to sign in yourself, rather than automating a login it would be blocked on anyway, and leaves the browser open at the end so you can check the run against the page it read.
Where it stopsOne commit and one hardcoded profile URL — it was never generalised past the first subject. The cached-action helper and the debug overlay that boxes what the model claims to see are both inherited from the Stagehand starter and never actually called. And the CSV writer joins on commas without quoting, so any profile whose About section contains one produces a malformed file. That last one is the first thing I would fix.
- TypeScript
- Stagehand
- Playwright
- GPT-4o
- Zod
AWS, in Terraform
A whole environment — load balancer down to database — from one apply.
A VPC with three public and three private subnets, an HTTPS load balancer, an auto-scaling group built from a golden AMI, Postgres in the private tier, S3, four customer-managed keys with rotation on, and an SNS-to-Lambda path that sends verification email. Coursework — Northeastern’s CSYE 6225, one pull request per assignment — but what it ends up provisioning is a real environment rather than a teaching VPC, and the point of it is that the whole thing is disposable.
The tiers enforce themselves. The database security group accepts 5432 only from the application group; the application group accepts 8080 only from the load balancer’s. Three tiers, and not one IP address written down anywhere — the topology is the rule, which means it cannot drift away from a comment describing it.
Subnets are computed rather than enumerated: cidrsubnet() over the availability-zone list, with the private block offset past the public count. Changing how many subnets you want re-lays the entire address plan without a hand-written CIDR, which is the difference between infrastructure code and a very long configuration file.
The database password is generated in-state by random_password and written straight to Secrets Manager under its own key, so it never touches a tfvars file or a shell history. Instances read it at boot from user-data, which also writes the CloudWatch agent config and restarts the unit. The AMI comes from a separate Packer build — the data source filters on a name prefix and takes the most recent, so a fresh image is picked up on the next instance refresh rather than by editing anything here.
Where it stopsIt is a flat root module — file-per-concern, no modules/ and no reusable interface — and state is local, so applies were manual. CI checks formatting and validates syntax on every pull request but never plans, which is the honest gap: nothing verified that a change did what its author thought before it was applied.
- Terraform
- AWS
- Packer
- GitHub Actions
Keep reading
BlockedInRead story (opens in a new tab)
CameraShyRead story (opens in a new tab)
AreYouAIRead story (opens in a new tab)