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.
- Next.js · TypeScript
- PostgreSQL · Prisma
- Stripe
- GCP
- ~3,800 automated tests
- In production since Oct 2026
- 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.
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.
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.
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.
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.