Abhiraaj Konduru

OneSource · Xenspire Group · April 2026 to now

HiringOS: making a service feel like a product.

A client platform for a recruiting firm. Clients request a search, approve the plan, review candidates, and pay in one place, and they can see every promise we made them and whether we kept it.

Problem
Recruiting firms sell a service, so clients only experience it through emails and calls. They can't see progress, the firm can't easily prove its value, and every new client costs more recruiter time spent answering "where are we?"
What I did
Designed and built the platform from an empty repo: data model, service templates, client and recruiter experiences, Stripe billing, permissions, and the release process.
Hardest decision
How much of the business to make configurable. Hardcode it and the platform breaks every time strategy changes. Make everything configurable and the settings become unusable. My line: engineers build the kinds of steps, admins compose them into services.
How I tested
Nearly 3,800 automated tests. Pure unit tests for logic that decides money and deadlines, integration tests against a real migrated Postgres with Stripe fixtures (duplicate and late webhooks, two tabs spending one credit), and walkthroughs with real users.
What I learned
"The payment request succeeded," "the client may start work," and "the money settled" are three different events. The database and the screen both have to keep them apart.

Why it exists

A SaaS company and a service company both deliver value to a customer. The difference is that SaaS can show it: you log in and see the thing working. A recruiting firm's value lives in emails, calls, and a recruiter's head. So it's hard to scale, because every client needs a person to keep them informed, and it's hard to sell, because you're asking someone to trust you instead of showing them a process.

HiringOS is the interface between the two. Before writing any code, I spent two months reading the firm's operating procedures and talking to recruiters and clients. The goal that came out of that was simple: a client should be able to see how their search is going, know what's waiting on them, and pay without asking anyone. Every question they don't have to ask is time a recruiter can spend serving another client.

The shape of the system

Three groups of people use one application. Everything they do passes through a single permission gate before it reaches the domain logic, and only the modules that need an outside service talk to one.

HiringOS architecture: three user surfaces pass through a permission gate into five domain modules backed by PostgreSQL, with Stripe, Cal.com, Resend and Cloud Storage outside. WHO USES IT DOMAIN MODULES OUTSIDE SERVICES Client portal Request a search, approve the plan, review candidates, pay Recruiter workspace Run the job, present candidates, keep promises Admin console Services, templates, prices, roles, and wording PERMISSION GATE Engagements and templates Each job is a live copy of a template Promise clocks Guarantees counted in business time Billing and credits Credit ledger, invoices, payment plans Scheduling Rotation across recruiter calendars Approvals and email Scanner-safe links, decision history PostgreSQL via Prisma 95 models · transactions guard money Cloud Storage Documents and hiring kits Stripe Payments, invoices, webhooks Cal.com Each recruiter's own calendar Resend Transactional email Runs as containers on a GCP VM with Cloud SQL. One command releases: check config, back up, migrate, swap.
One gate, many modules. The client portal, recruiter workspace, and admin console are the same app with different permissions. Nothing reaches a module without passing the gate, including server actions that no button links to.

Every job is an instance of a template

The firm's process changes constantly: a new kind of search, a different payment point, a new promise to clients. If each of those needed a developer, the platform would be the bottleneck. So I modeled a service as a template: a tree of stages and typed steps (tasks, approvals, calls, decisions, payments). When a client submits a request, the template is copied into the job, and from then on the job owns its steps. Editing the template changes future jobs, never the agreement a client already accepted.

The steps carry behavior, not just text. A payment step is done when its Stripe invoice is paid, not when someone ticks a box. A call step reflects a real booking. A step can have different wording for the client and the recruiter while pointing at the same piece of work.

A service template of stages and typed steps is copied into a job when a request is submitted. The job's steps then hold live state, and one progress model feeds both the client and recruiter views. SERVICE TEMPLATE Executive search Intake CALLIntake call APPROVALHiring Kit (client approves) PAYMENTSearch fee Search TASKShortlist promise: 5 business days after approval Candidates steps repeat per candidate request copied in, then owned by the job JOB · VP OF OPERATIONS Live state Intake callheld Oct 3 Hiring Kitapproved by client Search feeinvoice paid (Stripe) Shortlistdue Thu · 2 business days left Interview Maria?waiting on client One progress model, two views Client and recruiter always agree on what's done Edited in a visual builder or CLI Both share one validated contract
Template to job. The template is the plan; the job is the plan happening. Snapshots of terms and prices are stored on the job, so a catalog edit later can't rewrite what a client agreed to.

For admins maintaining complex templates, I also built a small CLI that pulls a template into a document, diffs it, and pushes it back. The main value was letting admins edit templates with AI tools like Claude Code while keeping everything in sync. That only works if pushes are safe, so they're validated against a strict schema, rejected if someone else changed the template since you pulled it, applied in a single transaction, and snapshotted so any version can be restored.

Promises that keep score of themselves

The firm guarantees things like "your shortlist within 5 business days of approving the Hiring Kit." That's where trust comes from, so the platform has to keep score honestly. Any template step can carry a clock: a promise (shown to the client, and a miss credits their account), a target (shown, no credit), or an internal deadline. The admin sets the event it counts from and how long it has, so changing a guarantee is a settings change, not a code change.

Status: the clock engine is built and tested on the current release branch and ships in the next release. Everything else on this page is in production.

Promise clock states: waiting, running, at risk or paused, then kept or missed. A missed promise credits the client. Waiting event hasn't happened Running business time only At risk close to the due time Paused waiting on the client Kept done by the due time Missed done late, or not yet Credit to client balance trigger event client owes us something done early automatic, or recruiter decides within a grace period Due time = start + duration + paused time (overlapping pauses count once). Weekends and holidays don't count. Once a clock is settled as kept or missed, the result is stored and never recalculated.
A promise clock. The whole calculation is one pure function of the step's clock, its pauses, the business calendar, and the current time. Every screen that shows a due date reads it from there, so two pages can't disagree about a deadline.

The edge cases are where the real work was. If the client is the reason we're waiting, the clock pauses, but a pause that starts after the deadline can't undo a miss. A clock counting back from a future start date doesn't pause at all, because the date doesn't move. A recruiter can move a promise date, but only with a reason, and the change is recorded.

Payments as a lifecycle, not a button

Clients can buy packages of service credits, pay invoices on net terms, spread a fee over a payment plan, and add extras to a running job. All of it is built on Stripe. The rule I designed around: the browser never decides what gets charged or granted, and a payment notification that arrives twice, late, or out of order must end in the same result.

Two paths confirm a purchase: Stripe's webhook and the client's return page. Both end in the same conditional database transition, so duplicates are harmless. PATH 1 · STRIPE TELLS US Webhook arrives maybe twice, maybe late Verify signature against the raw body Record event ID seen and done? reply 200 PATH 2 · CLIENT COMES BACK FIRST Return page before the webhook Ask Stripe for the payment's real state never trust the redirect or the browser One transaction UPDATE purchase SET status = fulfilled WHERE status = pending 1 row changed grant credits + ledger entries 0 rows changed someone already did it: no-op handler fails: save the error, reply 500, Stripe retries However many times and in whatever order the paths run, the purchase is granted exactly once. Tested against a real migrated Postgres with Stripe fixtures for repeated and delayed delivery.
Converging on one result. Event records catch exact duplicates, but the conditional update is what actually guarantees credits are granted once, whichever path gets there first.

Spending credits has the same problem in the other direction. If a client opens two tabs and both try to use their last credit, a "check, then update" in application code would let both through. Instead, the check is the update:

-- simplified: claim one credit, or nothing
UPDATE credit_grant
   SET reserved = reserved + 1
 WHERE id = $grant
   AND status = 'active' AND expires_at > now()
   AND total - reserved - consumed - expired - revoked >= 1;
-- 0 rows → that credit was just used. A database CHECK backs this up.

A job that needs several credits holds all of them or none. A few other distinctions mattered more than I expected: a bank payment that's submitted is not paid, so it has its own clearing state; and "your card was declined" is a completely different message from "we charged you but something broke after," because the second one must never invite the client to pay again.

Permissions as data

There are two worlds of access: people inside client organizations (owner, hiring manager, viewer...) and our own staff. Roles are stored as data and map to a catalog of permissions, with dependencies (you can't approve a job's documents if you can't view the job). Every server read and write checks the catalog, and the interface uses the same answer to explain things. If you can see a job but can't approve it, the page says "waiting on your team" and tells you who can, instead of showing a button that will fail.

To make sure nobody (including me) forgets a check, a test scans the source for every route and server action and fails if one isn't behind a permission gate.

Testing, releasing, and how I work

The most useful tests came from boundaries, not happy paths: two tabs spending one credit, terms changing while a client is reviewing them, the return page beating the webhook, a refund after credits were used, a stale template push, an approval link opened by an email scanner. Pure logic like the clock calculator and the permission map is unit tested; anything touching money runs against a real migrated Postgres.

I also own releases. One command checks the configuration, refuses to deploy unexpected local changes, builds the new image while the old one keeps serving, backs up the database, runs migrations in a temporary container, swaps the app, and waits for it to respond.

I build with AI coding agents. That makes the hard part deciding what the business needs and judging whether the output is actually right, which is why I wrote down how I keep intent and code in sync in Nomos. The decisions on this page are mine.

Hardcode it and the platform breaks every time the strategy changes. Make everything configurable and nobody can use the settings. The skill is choosing which pieces engineers build and which pieces the business assembles.