Trellion 2026 Updated
An AI interviewer that hiring teams can actually reason about
A hiring platform where the interview is a graph the team designs, the interviewer is a video agent that follows it, and every generated score arrives with a version stamped on it.
- Role
- Lead engineer (full-stack)
- Timeline
- Nov 2025 – May 2026; MVP built in six months
- Team
- Solo build: product and API
- Stack
- NestJS, GraphQL, Postgres, Redis, Next.js
Overview
Two audiences, one hiring record
Trellion is a hiring platform with an AI interviewer at the centre of it. A hiring team writes a role, designs the interview as a flow, and publishes it. A candidate applies, books a slot, and has a conversation with a video agent that follows that flow. Both sides then look at the same record: a transcript, a score with its reasoning, and coaching notes written for the person who sat the interview.
I built the product and the API underneath it as the only engineer, in six months from November 2025 to May 2026, and delivered the MVP: 48 screens in production. During the pilot, 10 companies ran more than 37 interviews on it. A GraphQL API owned the domain and every integration. One web application served both audiences, because a candidate and a recruiter were looking at two views of one hiring record rather than at two products.
- 62
- Test files in the API
- 65
- Schema migrations shipped
- 48
- Product screens in production
Problem
Screening does not scale, and the usual fix is worse
Screening is the part of hiring that scales worst. It is the same conversation repeated thirty times by the most expensive people in the company, recorded inconsistently, and compared from memory. The usual response is a keyword filter over CVs, which is fast and rejects the wrong people for reasons nobody can reconstruct afterwards.
An AI interviewer sounds like the obvious answer and is a worse product than it sounds, unless three things are true. The team has to be able to see and change what the interviewer will ask. The score has to be explainable next to the answer it came from. And the candidate has to get something back, because a person who gave up half an hour deserves more than an automated rejection.
- A black box is not reviewableIf a hiring team cannot see the questions in advance, they cannot stand behind the outcome, and neither can their legal team.
- Generated text is not a factA model will return a confident number for an empty transcript. Anything it produces has to be checked, versioned and attributable before it can sit next to a candidate's name.
- Two audiences, opposite accessA candidate and the company hiring them use the same records from opposite sides, and one of them must never see what the other sees.
- Realtime work that fails midwayA live video interview involves an external provider, a queue, and callbacks that arrive out of order. Any of it can fail while the candidate is still sitting there.
Discovery
What the first build taught me
The first version asked a fixed list of questions and scored the answers afterwards. It worked and it was useless. Every interview sounded the same regardless of what the candidate said, and a hiring team reading the results kept asking the same thing back: why did it ask that?
That pushed the interview from a list into a graph. Nodes are the moments of the conversation, edges are how it moves between them, and some of those edges only open when a condition holds. The part that mattered was ownership: the graph belongs to the hiring team, not to me. They open it, read every prompt, change a branch, and the editor refuses to save a shape that cannot run.
The second lesson was about the model layer. Treating generated output as text to render is what makes AI features rot: the prompt changes, the shape changes, and last month's stored result quietly means something else. Every generation in the system returns a validated object, and the schema version and prompt version travel with it into storage.
The third was scheduling, which I expected to be a form and which turned out to be a rules problem: a session that has to sit inside the job's application window, a cap on how much of the future one person can hold, no overlap with their own bookings, and a reminder that has to survive the interview being cancelled after it was already queued.
System design
One API, three kinds of work
One application, two audiences, and the services behind them. An authenticated request passes an edge that decides who is asking and whether this account may spend, reaches exactly one service that owns those rules, and only then touches storage or something external. Work that cannot finish inside a request is queued and comes back through the same services.
Product
Edge
Services
Data and providers
Product
- Candidate viewsOne application, apply and interview
- Session + role guard
- Recruiter viewsSame application, roles and review
- Session + role guard
- Public job pagesNo account required
- GraphQL API · public reads
- Provider callbacksInterview and payment events
- Webhook intake
Edge
- Session + role guardIdentity never comes from the request
- GraphQL API
- Entitlement guard · metered paths
- Entitlement guardChecked before work, recorded after success
- Interview orchestration
- Webhook intakeSignature checked, recorded, replay safe
- Interview orchestration · interview events
- Usage + limits · account events
Services
- GraphQL APIResolvers thin, services own the rules
- Primary database
- Cache, queues, realtime · cache + realtime
- Interview orchestration
- Background workers · enqueue
- Interview orchestrationFlow, agent config, scoring
- Model provider · generation + scoring
- Interview video provider · session config
- Background workersReminders, generation, provider sync
- Cache, queues, realtime · queue state
- Usage + limitsAllowances, counters, reconciliation
- Payments provider · reconciled on a schedule
- Primary database
Data and providers
- Primary databaseSystem of record
- Cache, queues, realtimeOne dependency, three jobs
- Model providerStructured output only
- Interview video providerRuns the live conversation
- Payments providerOwns account state
Solid lines are request paths. Dashed lines are out of band: queued work, and provider callbacks that arrive minutes after the response they relate to. Everything a third party sends enters through one intake that verifies the signature and records the event before any domain code runs, so a forged or replayed payload never reaches a service.
Two rules keep the modules apart. A resolver knows about the web and nothing else, and a service owns the rules for exactly one part of the domain. Cross-module work goes through another service's front door rather than reaching into its storage, which is dull right up until interviews, metering and applications all look like they need to change at once and only one of them actually does.
The provider boundary is where most of the care went. One package knows how the video agent is configured, what the interview graph translates into, and how its callbacks are shaped. Everything else asks for an interview session and gets a typed object back, or an error already phrased for a person. When the provider changes its surface, exactly one package changes, and its tests are the ones that fail first.
Realtime is the part users feel. A scored interview, a stage change, a new message and a finished generation all publish to subscribers over the same channel the queues run on, so a recruiter watching a pipeline sees a score land without refreshing. Nothing in the product polls for state the system already knows has changed.
Four stack decisions carry most of the weight, and each one is worth defending rather than assuming.
| Call | Why, concretely | What it costs | How that cost is paid |
|---|---|---|---|
| GraphQL rather than REST | Two audiences read the same records in very different shapes. A pipeline screen wants candidates with their profiles, their roles, their latest interview and its score in one round trip; a candidate's own screen wants a slice of the same graph. Over REST that is either several calls per screen or several bespoke endpoints that drift apart, and the mobile layout would pay for it twice. | The classic N+1 explosion, no free HTTP caching, and a schema that is easy to over-expose. | Related lookups are batched per request, caching is explicit rather than assumed, and every field is behind the same guards as the query that reached it. |
| One application for both audiences | Candidate and recruiter views share most of their surface: the same records, the same components, the same realtime channel. Two applications would mean two deploys, two sets of types and a slow drift between them. | Access rules have to be exact on every route, because the wrong view is now one URL away rather than one deploy away. | Access is decided on the server from the session, never from the route or a client flag, and the rules live in guards rather than in screens. |
| One cache and queue dependency doing three jobs | Caching, background queues and realtime fan-out are all in the same place. One operational dependency to run, watch and reason about instead of three. | It becomes a single point of failure for three unrelated features. | Cache misses fall through to the database rather than failing, queued work is at-least-once and safe to repeat, and realtime dropping out degrades to a page that still loads. |
| Postgres as the only store | Everything here is relational: people, roles, applications, interviews, events. Search, reporting and history all come off the same records, and that keeps one source of truth. | No dedicated search or analytics engine, so heavy reporting will eventually strain the same database the product runs on. | The read-heavy screens are batched and paginated, and adding a second store stays a deliberate later decision rather than an early habit. |
The stack calls. Every one of these buys something specific and charges for it somewhere else, and the third column is the part that matters in a review.
The team designs the interview
Nodes, branches and guardrails are edited on a canvas and validated before they can be saved: no orphan steps, no node with two sequential next steps, no branch without a condition. The saved graph is then pushed to the provider, so the agent runs the same thing the team just read.
On failureIf the push fails, the previously saved version is restored, rather than leaving the product describing one interview while the agent runs another.
A candidate books a slot
Scheduling is checked against the job's application window, a duration ceiling, the candidate's own bookings, and a cap on how many pending sessions one person can hold. A reminder is queued for shortly before the start.
On failureA failed reminder never fails the booking. Cancelling the session cancels the queued reminder with it.
The session starts
The agent's persona for this role is created on first use and reused afterwards, then a conversation is opened with the settings the role carries: language, duration, captions, and the graph itself.
On failureIf the agent cannot be configured, the session is refused before the candidate joins, rather than dropping them into a call that cannot work.
Events arrive out of order
Callbacks about one conversation do not arrive in the order they happened, and the provider does not send an id to key them by. Each event is given an identity derived from its own contents, written once under that identity, and the set of what has arrived is kept alongside it, so finalisation waits for the specific event it needs rather than for whichever happens to be last.
On failureA repeat delivery collides with the identity already stored and is dropped. An event for a conversation nobody is waiting on is acknowledged and ignored rather than retried forever.
The transcript becomes a score
Scoring runs against the questions the graph defined, returns a validated object, and is stored with the schema version, prompt version and model that produced it. Coaching feedback for the candidate comes from the same transcript in the same shape.
On failureOutput that does not match the schema is retried and then refused rather than stored. A transcript with nothing in it scores nothing rather than inventing a number.
The result moves the application
A score above the role's threshold can advance the candidate automatically, a score below it can close the application, and both are written to stage history with a reason attached. The final stage always needs a person.
On failureIf the application has already moved on or been withdrawn, finalisation records the result without overriding the human decision that got there first.

“Zana played a key role in building the frontend experience for Trellion.ai. He took ownership of complex parts of the product, delivered clean and reliable solutions, and helped turn ideas into a smooth experience for users. His attention to detail and technical skills made a real impact on the project.”
Correctness
Making generated output behave like data
A score is the most consequential thing this product produces, and a model will hand you one for an empty conversation if you let it. So generation is treated the way any other untrusted input is treated. Every call asks for a structured object, that object is validated against a schema before anything downstream sees it, and a response that does not fit is retried and then refused rather than stored.
Stored results carry their provenance: the schema version, the prompt version, the model, and when it ran. That one decision answers a question that otherwise has no answer six months later, which is why two candidates interviewed a month apart can be compared at all, and why a prompt change does not silently rewrite what an old score meant.
The same discipline covers the parts that cost money. Usage is recorded against a key derived from what was generated, for whom, and at which step, so a retry after a network failure cannot count twice. Account state that lives with the payments provider is not something this system tries to own; a scheduled pass walks it and repairs any drift against the local projection. Callbacks are the fast path, the sweep is the guarantee, and the two agree because both write through the same step.
On the read side the boring problem is the expensive one. A pipeline screen asks for candidates, their profiles, their roles, their latest interview and its score all at once, which is exactly the shape that turns one query into hundreds. Those lookups are collected within a single request, resolved as one query per relation, and handed back in the order they were asked for. The cache that makes it work lives and dies with the request, which is deliberate: a cache that outlived the request would be a way for one user's data to answer another user's question.
Decisions
The arguments worth having
Four product decisions did most of the work. Each had a faster option that would have shipped sooner and cost more later, and in each case the cost of my choice is the second thing I would raise in a review, not the last.
| Challenge | Obvious option | What I did, and why | What it cost |
|---|---|---|---|
| Hiring teams cannot audit a black box | Generate the questions and show the results | Made the interview an editable graph the team owns, validated before it can be saved. A team that can read the questions can defend the outcome, and a product that can be read can be reviewed by people who are not engineers. | A canvas and a rule set to maintain, and a harder first-run experience than a list of questions would have been. |
| Generated output drifting under the product | Call the model and render what comes back | Structured output validated against a versioned schema, stored with the prompt version that produced it, because an unversioned score is a number nobody can defend once the prompt has moved on. | Regeneration when a version changes, stricter prompts, and a retry path to maintain on every generated field. |
| Callbacks with no ordering and no ids | Finalise on the last event that arrives | Derived a stable identity per event from its own contents, wrote each once under that identity, and finalised on the presence of the event that matters. Last is not a property the provider guarantees, so building on it would have been building on a coincidence. | A little state per conversation with a lifetime on it, and one more thing to reason about when an interview does not finalise. |
| A third party that can fail mid-sync | Save locally and assume the provider followed | Snapshot before the push and restore that snapshot when the push fails, so the product and the agent can never describe two different interviews. | Two writes on the unhappy path, and a restore path that has to be tested because it almost never runs. |
The candidate feedback path was the decision I expected to argue about and did not. Generating coaching notes for someone the company is about to reject costs real money and returns nothing measurable to the buyer. It stayed because the alternative is a product whose entire value to one of its two audiences is a rejection email, and because the transcript and the scoring pass that produce it already exist.
One decision reads as process rather than architecture. The engineering standard for this codebase is written down and checked in beside the source: module layout, resolver and service boundaries, validation, batching, error mapping. It exists because the whole thing was built by one person over many months with AI assistance throughout, and the fastest way for that to produce an inconsistent codebase is to keep the conventions in one person's head. Every module also carries a short document describing its boundary and what it may not reach into, which is what makes a review of any single module possible without reading the rest.
Lessons
What building an AI product taught me about the non-AI parts
Almost none of the hard work was prompting. It was deciding what happens when the model is slow, wrong, or asked the same thing twice, and where in the system that is allowed to matter. Once generation sat behind a boundary that either returns a validated object or an error a person can read, the rest of the product stopped caring that it was talking to a model at all.
The other lesson was about who owns the artefact. The moment the interview became something a hiring team could open, read and change, the conversations about it changed shape. They stopped being about whether to trust the system and started being about whether the third question was the right question, which is the argument worth having.

