Skip to content

Huy TranFull-stack and AI engineer, Almere

A chat room that only opens while you are actually at the train station it belongs to. A multi tenant SaaS for salons. An offline first family app on the App Store. At Studio WIP, an agentic retrieval system that cites its sources or refuses to answer.

Open to full-stack and AI engineering roles in Amsterdam, or remote in the EU. Dutch work authorisation, no sponsorship needed.

Selected work

  • Solo. Design, mobile app, backend, infrastructure.Expo / TypeScript / Cloudflare Workers / GTFS-RTperron.thenextbeacon.com

    Perron

    A chat room for a station that only opens while you are actually at it, and closes behind you when you leave.

    250 mthe geofence, and it is a deliberate error in one direction
    The hard problem

    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.

    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.

    • 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
  • AI Engineer at Studio WIP. Solo: retrieval, chat, admin, evals, ingestion, deployment.TypeScript / Next.js / Postgres + pgvector / Transformers.js / Claude

    An agent that declines

    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.

    4independent access checks every chunk clears before the model is shown it: clearance, collection, company, department
    The hard problem

    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.

    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 WHERE clause. 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.

    • 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
  • Solo. Product, platform, mobile app, infrastructure.Next.js / Cloudflare Workers / Supabase / Expolorenly.com

    Lorenly

    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.

    9 → 1competing booking systems, normalised into one schema
    The hard problem

    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.

    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.

    • 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 an inline editable site and an Expo app per tenant
  • Solo. Go API, Expo app, sync engine, infrastructure.Go / Expo / SQLite / PostgreSQLOn the App Store since May 2026.fammedley.comApp Store

    FamMedley

    A family organiser that works with no signal at all, because the sync queue collapses itself before it ever reaches the network.

    9 of 50server tables mirrored onto the device, so the UI never waits
    The hard problem

    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.

    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.

    • 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

Experience

In 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.

  • Jul 2026 to PresentAmsterdam

    AI Engineer

    at Studio WIPCurrent

    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 to Jun 2026Amsterdam

    DevOps Engineer

    at In The Zone

    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 to Mar 2026Remote

    Full-stack Developer

    at Adamodigi

    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 to Nov 2022Amsterdam

    Full-stack Developer

    at AccentureInternship

    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 to Jul 2021Enschede

    Full-stack Developer

    at Code.rehabInternship

    React and GraphQL front end, Node microservices on AWS serverless for an online magazine platform, and a CI/CD pipeline between Jira and AWS.

Open to full-stack and AI engineering roles in Amsterdam, or remote in the EU. Dutch work authorisation, no sponsorship needed.

[email protected]