Greetings from
Huy Tran
Full-stack and AI engineer · Almere, the Netherlands
First the envelope, which has been through every job I have had since 2021. Inside are four postcards from the things I built this year: turn each one over to read it.
To whoever is reading this
From Huy Tran, Almere
Forwarded five times
Every address I have worked at since 2021, stuck on top of each other, the newest first.
Jul 2026 – Present
Studio WIPAI Engineer, Amsterdam
Agentic retrieval over an impact venture studio’s vetted claims. Grounding, citations, refusal, and a clearance boundary enforced in code rather than asked of a prompt.
Apr 2026 – Jun 2026
In The ZoneDevOps Engineer, Amsterdam
A life toolkit for bipolar. Owned deployment and infrastructure, maintained the Expo app, and built the internal analytics and CMS dashboard, plus an agentic dashboard that answers questions about operations and the app from internal data.
The company ran out of runway.
Jan 2023 – Mar 2026
AdamodigiFull-stack Developer, Remote
Rebuilt the payment flow and lifted conversion by roughly 15% in the months after launch. Hardened the deployment pipeline, and delivered a notification service end to end. Earlier, part time from 2021: SEO focused Next.js and headless WordPress builds, and geo detection at the edge with Cloudflare Workers.
May 2022 – Nov 2022 · Internship
AccentureFull-stack Developer, Amsterdam
Go, Python and React microservices deployed to GCP with Docker and Kubernetes through Azure Pipelines. Redesigned the dashboard around how the information is actually read.
Feb 2021 – Jul 2021 · Internship
Code.rehabFull-stack Developer, Enschede
React and GraphQL front end, Node microservices on AWS serverless for an online magazine platform, and a CI/CD pipeline between Jira and AWS.
Why the newest two are so close togetherIn The Zone ran out of runway in June, three months in. Studio WIP's founder had seen the agentic dashboard I built there and brought me in to take that idea further, which is the retrieval work above. I am not in a hurry to leave it, and I am open to the right thing.
Inside: four postcards from this year
No. 1 Perron

Greetings from the platform
Hello from the platform!
A chat room for a station that only opens while you are actually at it, and closes behind you when you leave.
Presence is the product, and presence is the thing a phone is worst at proving. A roofed station blocks GNSS, so the fix falls back to Wi-Fi and cell triangulation and lands tens to hundreds of metres off. Tighten the fence to make it mean something and you lock out the traveller standing on the platform, who is precisely the person the app is for.
— Huy
250 m
The number: 250 m, the geofence, and it is a deliberate error in one direction
Solo. Design, mobile app, backend, infrastructure.
Expo · TypeScript · Cloudflare Workers · GTFS-RT
On the Dutch App Store since August 2026, and on Google Play since October 2026.
Read the long letter about Perron
Waiting for a train is dead time, and everyone standing around you is waiting too. Perron opens an anonymous room for the people actually at your station, right now. Arrive and the room opens. Leave and it closes behind you.
Why the fence is 250 metres and not thirty
The romantic version of this app gates each platform separately. The phone cannot do it. Under a canopy or below ground the GNSS fix is gone and the device falls back to Wi-Fi and cell triangulation, accurate to somewhere between tens and hundreds of metres. Two platforms are twelve metres apart. There is no honest way to tell them apart from a consumer handset, so the room is the station, not the platform.
The radius is a deliberate error in one direction. At 250 metres it will sometimes admit somebody standing on the pavement outside, and that is the trade I want. Letting in a person who is nearly there costs the room very little. Turning away a real traveller who is standing exactly where they say they are costs me the user.
What the spoofing check buys, and what it does not
Fixes are checked for plausible travel between them, capped at 300 km/h, comfortably above the fastest Dutch rail so that GPS jitter does not trip it. That rejects teleportation across the country.
I want to be precise about what it does not do. A patient attacker who moves slowly is not caught by it. Real enforcement is device attestation, and this is not that. The check raises the cost of casual spoofing, and I would rather say so than imply a guarantee the code does not make.
Refusing the vendor API
The obvious way to get Dutch train data is the carrier’s own API. I did not use it. A vendor API means a key that can be revoked, a rate limit that can be tightened, and a licence that covers one operator.
Instead the app reads the open CC0 GTFS-RT feeds the Dutch transit sector publishes, parsed and reconciled on a Cloudflare Worker. It is more work up front. In exchange the app covers every operator on the network, owes nobody a licence, and cannot be switched off by a business decision I do not control.
The line the fence does not cross
Location gates the social features. It never gates the timetable.
Chat, the station wall and the passport stamps sit behind the fence, because being there is the point. But departure times, delays and platform changes are why a person opens a train app at all, and somebody running for a train they are about to miss should never be told to walk closer to a sensor. Status is always readable. Only the conversation is earned.
Things you can check
- 250 m geofence around the station, sized to admit someone on the pavement rather than reject a real traveller standing indoors
- Spoofing bounded by a 300 km/h travel plausibility check between fixes, which stops casual teleportation and nothing stronger
- Train rooms use a 2 km radius, because a live train position is an estimate between stations that can be 30 km apart
- Built on open CC0 feeds, so it covers every operator on the Dutch network rather than one carrier
No. 2 An agent that declines

Greetings from the library
Hello from the library!
Retrieval over an organisation's vetted claims, where every chunk clears four access checks before the model sees it, and the failures are the intrusion signal.
When the corpus is an organisation's vetted claims, a hallucination is a compliance failure rather than a UX glitch. And retrieval leaks: a reader must never receive a passage from a document above their clearance, not summarised, not paraphrased, not quietly folded into an answer that sounds harmless.
— Huy
4
The number: 4, independent access checks every chunk clears before the model is shown it: clearance, collection, company, department
AI Engineer at Studio WIP. Solo: retrieval, chat, admin, evals, ingestion, deployment.
TypeScript · Next.js · Postgres + pgvector · Transformers.js · Claude
Read the long letter about An agent that declines
Studio WIP is an impact venture studio. It runs on documents: research, evidence, interviews, the things a venture has actually established to be true. The agent lets a person interrogate that body of work without quietly inventing the parts it does not have.
I built the whole surface, and most of it is the surface you would expect: multi user chat, shared sessions, collections, an ingestion queue, an admin dashboard for roles and documents, an eval harness, a containerised deploy. None of that is why the project is interesting. The rest of this is about the one decision inside it that was not obvious, and about which of its guarantees are real.
The query is deliberately not filtered
The obvious way to enforce a clearance boundary is to put it in the
WHEREclause. This one does not. Both retrieval queries, the vector search and the full text search, run unrestricted, and the access rules are applied afterwards in one pure function that is exhaustively unit tested: clearance level, collection allowlist, company, department. Four checks, and a chunk has to survive all of them before its text is ever placed in front of the model.It looks like the weaker design and it is the reason the interesting thing works. Because the candidate pool is unfiltered, the system can see the difference between what matched the question and what the reader is allowed to read. That difference is a signal. A denied chunk that scored above 0.45 cosine, or landed in the top three of the text search, is not a coincidence: somebody has asked a question shaped like a document they cannot open. The system records it as an access attempt, names the dimension that failed, and an admin can go from the audit entry straight to the conversation that produced it.
Filter in the query and you get the same safety and you are blind. You cannot log what you never retrieved.
Citations you can land on
A chunk keeps the line range it came from, recovered by locating the piece back inside the original text rather than guessed at. So a citation is not a document name, it is a place: the reader clicks it and the source opens at those lines. Tables cite a row range. PDFs cite a page.
What is actually enforced, and what is asked
I want to be exact here, because it is the difference between a guarantee and a habit.
The clearance boundary is enforced in code. There is no prompt on earth that will persuade the agent to cite a document it was never handed, because a boundary a prompt is asked politely to respect is not a boundary.
Grounding and refusal are asked for in the prompt. The answerability gate, the rule against answering from the model’s own priors, the requirement to cite: those are instructions, and instructions are followed most of the time rather than always. So I grade it. A sample of real turns is scored for groundedness and citation validity, and the score goes on the admin dashboard rather than into a slide.
The first honest reading was groundedness 0.70 and citation validity 0.64. That number is low, and chasing it turned out to be more interesting than the number itself: the grader was resolving citations against the whole knowledge base rather than against the evidence that particular answer had actually been shown, so it was marking good citations wrong. It now grades against the turn’s real evidence. Measuring the agent honestly first meant fixing the instrument before the agent.
The eval suite is where refusal is held in place: cases that must decline, and, just as importantly, nineteen assertions that must not decline. Over-refusal is the real cost of a system tuned to say no, and it is easy to tune the pendulum too far and end up with an agent that is useless and technically correct. Those nineteen cases are what stop me doing that quietly.
Things you can check
- Documents are parsed, chunked at 1200 characters with 150 of overlap, and embedded on the box with a local 384 dimension model. No text leaves for a third party embedder
- Retrieval is hybrid: pgvector cosine and Postgres full text, fused with reciprocal rank, capped at three hits per file
- Citations carry a real line range, recovered by locating each chunk back in the source, and the reader can open the document at exactly that range
- Denied chunks that scored highly are written to an audit log as an access attempt, with the failing dimension named
No. 3 Lorenly

Greetings from the salon
Hello from the salon!
Salon software you can actually switch to, because it lifts your clients, your bookings and your gift cards out of the nine systems you might be leaving.
You cannot win a salon that already has four years of clients and bookings sitting inside a competitor's product. The switching cost is the whole market, so the import has to be good enough to be the reason they move. That means nine export formats, each with its own column names and date formats, duplicate clients spelled three ways, bookings pointing at services that no longer exist, and gift card balances that have to survive the move exactly, because that money is a liability the salon still owes somebody.
— Huy
9 → 1
The number: 9 → 1, competing booking systems, normalised into one schema
Solo. Product, platform, mobile app, infrastructure.
Next.js · Cloudflare Workers · Supabase · Expo
Salon app on the App Store since July 2026, and on Google Play since October 2026.
Read the long letter about Lorenly
Booking, client records, gift cards, payments, and a website for each business. The product surface is ordinary. The thing that decides whether any of it gets used is the door.
The importer is the door
A salon owner does not evaluate booking software on features. They evaluate it on whether the four years of client history they are sitting on survives the move. If the answer is export a CSV and retype it, the answer is no, and no feature built afterwards changes that.
So the import reads nine competing systems, and each one is its own small hostile world. Different column names, different date formats, the same client spelled three ways across three years, bookings that reference services which no longer exist, and gift cards whose balances have to land exactly right, because an unredeemed gift card is money the salon still owes a person who is going to walk in and ask for it.
The work is normalisation, deduplication, and being repeatable enough that a run which dies halfway can be run again without charging anybody twice. It is not glamorous engineering. It is the reason a salon can say yes.
Multi tenant without a database per tenant
One shared Postgres schema. The tenant is resolved per request from the Host header inside the Worker, and Next.js runs on Cloudflare Workers through OpenNext. Each tenant gets a site they edit inline, in place, on the page, rather than through a form that describes the page.
One schema, and the guarantee I would move into the database
A shared schema is cheap to run and it concentrates the risk in one place: every tenant lives in the same tables, so isolation has to be enforced on every path that touches them. Row level security pushes that guarantee down into Postgres, where it holds whether or not the person writing the next query remembers it, and that is the change I would make before this platform carries an order of magnitude more salons. The cheaper model was the right way to find out whether salons would switch at all. It is not the model I would scale on.
Things you can check
- Imports clients, past bookings and gift card balances from 9 competing booking systems
- Multi tenant on one shared Postgres schema, tenants resolved per request from the Host header inside the Worker
- Next.js on Cloudflare Workers through OpenNext, with a website each tenant edits inline, in place
- One Expo app on the App Store for the whole platform: the salon is resolved from the signed in account, not from a build per tenant
No. 4 FamMedley

Greetings from the kitchen table
Hello from the kitchen table!
A family organiser that works with no signal at all, because the sync queue collapses itself before it ever reaches the network.
Every device mutates the same shared state, and half of them are offline when they do it. A list edited on the train and the same list edited in the shop have to converge on reconnect without duplicating rows, dropping the queue, or resurrecting something a person deleted. The expensive answer is a merge engine. The question is whether a family shopping list is worth one.
— Huy
9 of 50
The number: 9 of 50, server tables mirrored onto the device, so the UI never waits
Solo. Go API, Expo app, sync engine, infrastructure.
Go · Expo · SQLite · PostgreSQL
On the App Store since May 2026, and on Google Play since October 2026.
Read the long letter about FamMedley
A shared brain for a household: lists, plans, a whiteboard, where everyone is. The features are easy to describe. The hard part is invisible, which is the usual shape of this kind of product.
The queue is the engine
Each device holds its own SQLite database and the UI reads only from it, so the app works in a basement. Every write also appends a row to an outbox table: an identifier generated on the device, the entity type, the action, a JSON payload, and a client timestamp.
A sync fires two seconds after the last edit, on foreground, or the moment connectivity returns. Before anything is sent, the queue collapses, and that is where the work is. All pending mutations for one entity fold into exactly one. Consecutive updates merge their payloads. A create stays a create even if it is edited afterwards. A delete beats everything. And a net zero toggle, checking a shopping item and then unchecking it, disappears without ever reaching the network. If the collapse leaves nothing, no request is made at all.
Replay turns out to be safe without an idempotency key. The identifiers are generated on the device, writes are find then create or update, and the outbox is cleared only after the server returns 200. The batch applies inside one Postgres transaction, so a sync either happened or it did not.
Row level last write wins, and the point where I would replace it
Conflicts resolve as last write wins at the level of the row. Each mutation carries a client timestamp, and if the row on the server has been touched more recently, the mutation is dropped. Not merged, not partially applied, not rejected loudly. Dropped, whole, in silence.
So this can happen. You edit a task’s title on the train at ten. Someone else changes that task’s colour at five past, online. You reconnect, your timestamp loses, and your title is gone, even though the two of you never touched the same field. A field level merge would have kept both. A row does not know what a field is.
It is a deliberate trade rather than an oversight. Per field timestamps, or CRDTs, cost more to build and more to reason about than a household shopping list is worth, and the lost edit is rare. But it is the first thing I would rewrite if this app ever grew a document, or anything two people edit at once on purpose, because at that point last write wins stops being thrift and starts being data loss.
Things you can check
- Every device owns a SQLite database of 9 tables, mirroring 7 of the server's 50, so the UI never waits on the network
- Writes land locally, queue in an outbox, and replay when the connection returns
- The queue collapses before it is sent: one mutation per entity, delete always wins, and a check then uncheck never leaves the phone
- The server applies the whole batch in a single Postgres transaction, so there is no half applied sync to reconcile
The stamp album
Smaller things I made in the evenings, one stamp each.

10 Million Cells
A fleet of balls erodes ten million cells of rock to uncover the pictures under it. Moved from Godot to my own C++ engine, so 400 balls hold 50+ fps on a 2019 phone.
On Google Play.

Scrap Heap
A backpack roguelike auto-battler. Buy junk, weld it into better junk, fit it on a grid. Combat is a deterministic simulation with no engine code inside it.
On Google Play.

Mahjong Moonwindow
A calm mahjong solitaire with 4,000 boards in about 0.8 MB. Each board is a seed, regenerated on the phone and solvable by construction.
On Google Play.

Connect Garden
A tile connect puzzle made for players over 50, with large tiles, large text and 20,000 levels. It plays offline, and new tile packs arrive without an app update.
On Google Play.

Playwave
A native iPhone music player that merges Navidrome, Jellyfin, Plex, Emby, WebDAV and DLNA libraries into one, in 10 languages.
For iPhone.
Beacon
The shared backend under the games. Event analytics with a dashboard, and cloud saves where a lost race costs the write but never the bytes.
Live.
The homelab
A Proxmox cluster at home. Local LLMs and speech to text run on a laptop-class iGPU through Vulkan, because ROCm hangs on it. It also hosts the CI runners.
Running.
Write back?
Open to full-stack and AI engineering roles in Amsterdam, or remote in the EU. Dutch work authorisation, no sponsorship needed.