• Home
  • Projects
  • Resume

Remote

GitHubX

Remote · GMT+4

Trellion 2026 Updated 15 Oct 2026

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.

Visit live site
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.

What it replaces

The first screening round. Not the judgement, the logistics: the scheduling thread, the half hour on a call asking the same six questions, the notes nobody writes up, and the silence a candidate gets afterwards. The team still decides. They decide from a transcript, a scored breakdown they can argue with, and a record of what the candidate was actually asked.

62
Test files in the API
65
Schema migrations shipped
48
Product screens in production

About these screens

Every screenshot in this study runs on generated demo data. The names, roles and transcripts are invented, because hiring records are among the most sensitive data a product can hold and none of it belongs in a portfolio.

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.

public readsmetered pathscache + realtimegeneration + scoringsession configenqueuequeue stateinterview eventsaccount eventsreconciled on a schedule

Product

Edge

Services

Data and providers

Candidate viewsOne application, apply and interview
Recruiter viewsSame application, roles and review
Public job pagesNo account required
Provider callbacksInterview and payment events
Session + role guardIdentity never comes from the request
Entitlement guardChecked before work, recorded after success
Webhook intakeSignature checked, recorded, replay safe
GraphQL APIResolvers thin, services own the rules
Interview orchestrationFlow, agent config, scoring
Background workersReminders, generation, provider sync
Usage + limitsAllowances, counters, reconciliation
Primary databaseSystem of record
Cache, queues, realtimeOne dependency, three jobs
Model providerStructured output only
Interview video providerRuns the live conversation
Payments providerOwns account state

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.

CallWhy, concretelyWhat it costsHow that cost is paid
GraphQL rather than RESTTwo 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 audiencesCandidate 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 jobsCaching, 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 storeEverything 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.

  1. 01

    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.

  2. 02

    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.

  3. 03

    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.

  4. 04

    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.

  5. 05

    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.

  6. 06

    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.

Where cost enters the flow

Generation-heavy actions check an allowance before they run and record usage only after they succeed, so a failed call never costs an account part of its limit. Limits come from configuration rather than from code, which means changing one is data and not a deploy.

“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.”
Majid MohisenTrellion AI

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 cache key is the contract

Scoring is keyed by a digest of everything that could change the answer: the transcript, the questions the graph defined, the threshold, and the schema and prompt versions. Identical inputs never pay for a second generation, and bumping a prompt version invalidates every result derived from the old one without anyone remembering to clear a cache.

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.

ChallengeObvious optionWhat I did, and whyWhat it cost
Hiring teams cannot audit a black boxGenerate the questions and show the resultsMade 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 productCall the model and render what comes backStructured 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 idsFinalise on the last event that arrivesDerived 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-syncSave locally and assume the provider followedSnapshot 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.

Where the time went: versioning what a model wrote

Versioning generated output so old results stay meaningful. Making out-of-order callbacks land exactly once. Metering that never charges for a failure. None of it demos well, and all of it is why nobody has had to ask where a number came from.

Explore more

  • Merchant home on a laptop and a phone: the USDC account, private payments and bank account, takings for the last 30 days, and recent paymentsA payment gateway where the shopper stays private and the merchant still gets paidWisp 2026
  • Admin dashboard: logged hours, active clients, total payout and the anomalies feedOne system for mining clients, coaches and referral partnersLeveraged Mining 2025
  • A toast library you can call from anywhere, including outside Reactembertoast 2026

Have a project in mind?

I take on a small number of projects at a time. If you want to talk through yours, book a call or send an email. I reply within a day.

Book a callhey@zanastack.com

Links

  • Home
  • Projects
  • Resume
  • For language models

Contact

  • hey@zanastack.com
  • Schedule a call

Zana Salimi · Relocating to Yerevan · Remote (GMT+4)

Copyright © 2026 Zana Salimi.

Zana Salimi