Requisition and posting draft
Works todayA structured requisition, deterministic posting copy, and a rules-based exclusionary-language scan that reports rather than rewrites. Every draft version and its flags are kept.
Available now
In build
The whole team
Nineteen specialists, each with a defined job and an honest status label.
See all nineteenWhat Bharti does and refuses to do: it organises hiring evidence and cannot reject a candidate itself — a database constraint refuses that. Capabilities, the API, and limits.
Bharti turns a hiring need into a job requisition, tracks every candidate through a pipeline your workspace defines, runs structured interview kits with blind scorecards, and gives a hiring team a place to see the evidence together before they decide. It never decides for them: a database constraint, not a policy, refuses any adverse stage move that was not made by a named person.
Cannot reject or archive a candidate on its own
A stage-transition row that is both adverse and not made by a named human is refused by a CHECK constraint at the database — not just by a rule in the API — and the service layer refuses it a second time before that row is ever attempted.
No protected-characteristic column anywhere in the schema
Not on a candidate, an application, or a requisition. Age, gender, marital status, religion, caste, disability and twelve other attributes cannot be stored, filtered, or scored on, even when a candidate's own resume or message states them, and even a create request that tries to smuggle one in through an extra field is refused outright.
A screening result is factors, never a verdict
Bharti stores the contributing factors and computes their sum live on every read — there is no stored score column — but the number never travels without the reasons behind it, and it never becomes a decision on its own: every screening response carries decision: null and decided_by: "human_required".
A search that selects on a protected characteristic is refused
Not silently filtered — refused. Results are never ranked by any score — ordered only by when the record was created — and a match explanation names the specific fields that matched, never a percentage.
An interviewer cannot read a colleague's scorecard first
Every candidate's evaluation is blind until the interviewer's own scorecard is submitted, and an interviewer cannot dodge the lock by simply never starting one, or by removing themselves from the kit's expected-interviewer list.
A hiring-team roundup can never surface a composite score
No average, no "AI recommendation" at the moment of decision. The route asserts its own payload carries none, recursively, before returning it — the exact moment a visible number would do the most damage to turn a decision-support view into the decision itself.
Sending a rejection or status update still needs a decision, and an approval, a human already made
POST /rejection-messages/{id}/send only works from status: approved, and only once — the underlying decision (a human roundup reject) and its approval (a named person) both have to already exist. There is still no code path from a score, a stale-application rule, or an automated stage move to a send. Email delivers for real today, through this deployment's already-configured ZeptoMail account; WhatsApp needs its own Meta credential and dry-runs until one is added, and no SMS client exists anywhere in this codebase for either channel. Offer dispatch is the one that still cannot send at all: there is no e-signature provider configured, so an approved offer has no route to leave the approval chain and reach a candidate.
Getting started
Turn a plain-language brief — title, must-have skills, pay band, location — into a structured requisition. Any must-have or nice-to-have skill that reads as a protected characteristic is refused before the row is even created, and the generated posting copy is scanned for exclusionary language and returned with flags rather than silently rewritten. Nothing leaves draft without a named human approver.
Applications arrive against your own pipeline stages. Every move writes a full transition row — from, to, whether it was adverse, who moved it and whether that actor was human — and an adverse move with no named human actor and no reason is refused at the database, not just at the API.
Build a kit with categories every interviewer will be asked, and each interviewer's scorecard stays locked from their peers until they submit their own. When it's time to decide, a roundup view lays every submitted scorecard side by side with no average and no composite — the decision, and the written rationale for it, is a named person's.
Capabilities
A structured requisition, deterministic posting copy, and a rules-based exclusionary-language scan that reports rather than rewrites. Every draft version and its flags are kept.
Configurable stages, append-only stage transitions naming who moved a candidate and whether they were human, and a screening signal stored as contributing factors with no score column.
The self-hosted careers-page channel is real, end to end: publishing with board_names: ["careers_page"] makes a role reachable at a public URL, and applying there creates a real, attributed application. Naukri, LinkedIn Recruiter/Job Postings and Indeed have no client anywhere in this codebase — each needs a partner business relationship this deployment has not verified, not a self-serve key — so publishing to one of them records the attempt as failed, naming the gap, rather than a fabricated HTTP call against a guessed contract.
Needs: A verified partner relationship with Naukri, LinkedIn, or Indeed to publish there — the careers page itself needs nothing.
Skill, location and experience search returning the fields that matched, never a ranking, and refusing any query that selects on a protected characteristic. The search index is kept current inline on every candidate create or correction, rather than by a scheduled job, and can be rebuilt on demand. Saved searches are refused on the identical protected-characteristic terms a live search already is, so a standing filter cannot smuggle one through.
Runs on the shared document-extraction platform, bridged into Bharti's own candidate records: a field whose confidence clears Bharti's own threshold applies automatically, and everything below it lands in needs_review_fields rather than being trusted on a low-confidence read — the specification's own risk about non-Latin-script and non-standard resumes. Date of birth, gender and marital status are withheld by the extraction allowlist even when a resume states them, so they never reach Bharti's own candidate columns. Prose employment history — not just labelled fields and delimited rows — now parses too, since the LLM pool is configured on this deployment.
Needs: A scanned or photographed resume needs a document-AI OCR credential (DOCAI_OCR_API_KEY), which this deployment does not have. A typed or text-layer resume already parses fully.
Blind-until-submitted is enforced in the backend, keyed on the kit's expected interviewers so it cannot be dodged by not starting a scorecard.
Raw per-interviewer evidence with no average, composite, or recommendation — asserted against the route's own payload before it returns. A decision requires a named decider and a written rationale.
Both-party consent to record is CHECK-constrained at the database level, not only checked in the route. A transcript pasted in — a video provider's own captions, for instance — drafts a per-category note today with no credential beyond the already-configured LLM pool, and accepting a draft writes only into the scorecard's free-text comment, never into a per-attribute score — the structural answer to the ASR/HireVue disability-discrimination risk this capability's own specification names. Expired recordings purge on an on-demand retention route.
Needs: Turning a call recording itself into text needs a speech-to-text provider, which this deployment has no client for. Pasting an existing transcript in works today with nothing configured.
Drafting is gated on an existing human roundup rejection, uses de-identified scorecard themes (score ranges and interviewer counts, never names or free-text comments), and is approved by a named person. Sending now exists, reachable only from an approved message and only once: email delivers for real today, through this deployment's already-configured ZeptoMail account.
Needs: WhatsApp delivery needs its own Meta credential (META_ACCESS_TOKEN, META_PHONE_NUMBER_ID) — until then it stays a dry run that never claims sent.
An expiring, token-authenticated portal showing the same stage staff see — no scorecard, score, or reason note. Withdrawal propagates across every application and revokes every live link.
Templates, a manual send and message history all run against this workspace's own data. Delivery is real for two of three channels: email sends through the same configured ZeptoMail integration (verified working, not merely wired), and WhatsApp goes through the real Meta Cloud API client, which dry-runs and never claims sent without its own credential. A stage-triggered dispatcher fires inline the moment a human moves a candidate's stage — it notifies of a decision already made, never makes one itself.
Needs: No SMS gateway client exists anywhere in this codebase for the third channel — that is a missing integration, not a missing key, which is why this stays partial.
The conversation engine itself is complete and locally testable: a fixed four-question flow in four hand-checked languages, gated on explicit consent before any question is asked, with the full transcript retained. Completing the flow creates a real candidate and application, exactly like any other source. A WhatsApp-sourced candidate who is later archived is notified through the same stage-triggered dispatcher candidate-messaging-and-status-updates already provides, once a template exists for that stage.
Needs: Two real gaps: sending anything over WhatsApp needs a Meta credential (META_ACCESS_TOKEN, META_PHONE_NUMBER_ID), and no route here is wired to Meta's actual inbound webhook yet — that dispatcher is shared across agents and out of scope for this slice.
Holds, candidate self-booking and a visible accommodation request at the booking step all work locally today.
Needs: Real availability needs a connected Google or Microsoft calendar; every hold is a recruiter's assertion until then, and reminders need a messaging channel too.
Offer drafting anchored to the role's pay band — never a prior-salary figure, for which there is no column — with an in-order approval chain snapshotted at submission.
Needs: Dispatch for signature needs an e-signature provider (DocuSign, Adobe Sign, or an India-compliant equivalent).
Consent capture, the request and result records, and the structural refusal are all real: a result cannot be recorded before consent is on file, checked both in the route and by a database CHECK constraint underneath it, and nothing anywhere in this codebase reads a background-check result into a stage-move decision.
Needs: No India BGV provider — EPFO/UAN employment history, PAN/Aadhaar-linked identity, DigiLocker education — has a client here. Each needs an authorised-channel relationship with a licensed intermediary, a business decision no operator has made yet, not merely an API key.
A policy record and accommodation-request tracking, structurally unreachable from screening. An adjustment cannot be marked unfulfillable without a written reason, and nothing auto-declines.
Live stage counts, days-in-stage and conversion, computed on read. An immutable daily snapshot is now built and idempotent per day — a second call the same day hands back the existing row rather than overwriting it, so a later dispute about a historical count has one permanent answer. Per-board source performance groups by the source_board every application already carries, with no external dependency.
Membership is the candidate's own talent-pool consent, not an inference from having applied once — a worker now runs inline when a requisition closes, flagging every candidate who reached an active stage or has scorecard evidence, using documented, job-relevant signals only, never a private note. Every rediscovery run is persisted for audit, and re-engaging a candidate always asks for fresh, per-role consent rather than assuming a standing one. Email delivers for real today.
Needs: WhatsApp re-engagement needs the same Meta credential (META_ACCESS_TOKEN, META_PHONE_NUMBER_ID) rejection sending waits on — restricted to email/WhatsApp since no SMS client exists anywhere in this codebase.
API surface
Every route below is mounted and reachable today. Requests and responses are real shapes, not illustrations.
/api/v1/agents/bharti/requisitionsCreate; refuses a protected-characteristic skill Six default stages (Applied through Hired/Archived) are created with the requisition.
Request
{
"title": "Warehouse Associate",
"brief_text": "Loading/unloading, inventory counts, forklift a plus.",
"location": "Pune", "shift_pattern": "Rotating 8-hour shifts",
"pay_band_min_minor": 1800000, "pay_band_max_minor": 2400000, "currency": "INR",
"must_have_skills": ["inventory management"], "min_experience_years": 1
}Response
201
{ "id": "...", "status": "draft", "approved_by": "", "incomplete_fields": [] }/api/v1/agents/bharti/requisitionsList, filterable by status
/api/v1/agents/bharti/requisitions/{id}One requisition plus stages and posting versions
/api/v1/agents/bharti/requisitions/{id}Edit; re-runs the protected-skill refusal
/api/v1/agents/bharti/requisitions/{id}/generate-postingDeterministic posting draft with exclusionary-language flags
/api/v1/agents/bharti/requisitions/{id}/approveNamed-human approval; flips draft to open
/api/v1/agents/bharti/requisitions/{id}/stagesEdit pipeline stages
/api/v1/agents/bharti/requisitions/{id}/pipelineApplications grouped by stage
/api/v1/agents/bharti/requisitions/{id}/stale-applicationsFlags only; proposed_action is always human_review
/api/v1/agents/bharti/applicationsCreate an application
/api/v1/agents/bharti/applications/{id}One application plus candidate
/api/v1/agents/bharti/applications/{id}/move-stageRefused if adverse and non-human The same refusal exists twice: this service-layer check, and a CHECK constraint on the transitions table that refuses the row even if the service check were bypassed.
Request
{ "to_stage_id": "<archived-stage-id>", "actor_type": "system", "actor_rule": "stale_after_60_days" }Response
403
{
"message": "An automated rule cannot archive or reject a candidate. It may flag the application for review; a person has to make the call.",
"remedy": "Surface this application for human review, then move it as a named user with a reason."
}/api/v1/agents/bharti/applications/{id}/timelineEvery stage transition, with actor and reason
/api/v1/agents/bharti/applications/{id}/screenScore contributing factors, never a stored score score is summed live from the stored factors — there is no score column in the database — and it never appears without them.
Response
200
{
"application_id": "...", "score": 30,
"factors": [
{ "kind": "must_have_skill_present", "label": "inventory management",
"detail": "Profile lists the must-have skill 'inventory management'.",
"evidence_field": "must_have_skills", "points": 14 },
{ "kind": "experience_meets_minimum", "label": "Experience",
"detail": "2 years stated, against the 1 the role asks for.",
"evidence_field": "years_experience", "points": 16 }
],
"decision": null, "decided_by": "human_required",
"note": "A screening score is a prompt to read the application, not an outcome. Advancing or archiving is a person's call."
}/api/v1/agents/bharti/applications/{id}/screeningLatest run, recomputed from stored factors
/api/v1/agents/bharti/candidatesCreate; extra="forbid" refuses an unrecognised field
/api/v1/agents/bharti/candidates/searchRefuses a protected-characteristic query term The refusal names the term and offers a job-relevant rewording, rather than a bare error.
Request
{ "query": "young energetic warehouse staff" }Response
422
{
"message": "'young' selects on age, which Bharti will not filter or score on. 'Young' describes the applicant, not the work.",
"remedy": "Name the demand instead — 'this role involves being on your feet for most of the shift'."
}/api/v1/agents/bharti/candidates/{id}/match-explanationNamed fields, never a percentage
/api/v1/agents/bharti/requisitions/{id}/interview-kitCreate the kit and its question categories
/api/v1/agents/bharti/interview-kits/{id}One kit
/api/v1/agents/bharti/interview-kits/{id}Edit; blocks self-removal from the interviewer list
/api/v1/agents/bharti/scorecardsUpsert your own scorecard only
/api/v1/agents/bharti/scorecards/{id}/submitSubmit; unlocks your view of peers
/api/v1/agents/bharti/applications/{id}/scorecardsPeers redacted until you submit your own
/api/v1/agents/bharti/applications/{id}/schedule-roundupSchedule a decision discussion
/api/v1/agents/bharti/applications/{id}/roundup-viewEvery scorecard side by side; no composite
/api/v1/agents/bharti/roundups/{id}/record-decisionNamed decider plus written rationale, required
/api/v1/agents/bharti/applications/{id}/draft-rejectionGated on an existing human reject decision
/api/v1/agents/bharti/rejection-messages/{id}Edit a draft; re-gates on save
/api/v1/agents/bharti/rejection-messages/{id}/approveNamed-human approval
/api/v1/agents/bharti/rejection-messages/{id}/sendOnly from status: approved, only once — email delivers for real, WhatsApp needs its own Meta credential
/api/v1/agents/bharti/applications/{id}/send-messageManual candidate message, same email/WhatsApp channels
/api/v1/agents/bharti/applications/{id}/message-historyEvery message actually sent or dry-run, with its status
/api/v1/agents/bharti/portal/{token}/statusStage name only
/api/v1/agents/bharti/portal/{token}/withdrawPropagates across every application
/api/v1/agents/bharti/applications/{id}/interview-slotsA hold, pending a connected calendar
/api/v1/agents/bharti/applications/{id}/generate-offerAnchored to the role's pay band
/api/v1/agents/bharti/offers/{id}/submit-for-approvalSnapshots the approval chain in order
/api/v1/agents/bharti/offers/{id}/approveStrict-order approval only
/api/v1/agents/bharti/requisitions/{id}/publish-to-boardsReal for careers_page; every other board records an honest failed attempt
/api/v1/agents/bharti/careers/{id}The public self-hosted careers page for one requisition
/api/v1/agents/bharti/whatsapp-sessions/advanceDrives the four-question consent-gated flow, one message at a time
/api/v1/agents/bharti/whatsapp-sessions/{id}/convert-to-applicationCreates a real candidate and application once the flow completes
/api/v1/agents/bharti/applications/{id}/background-checkConsent required before any result can be recorded
/api/v1/agents/bharti/transcripts/{id}/draft-notesPer-category draft from a pasted transcript, LLM pool only — a GET in the router, whatever a POST would suggest
/api/v1/agents/bharti/scorecards/{id}/accept-draft-notesWrites only into the comment field, never a per-attribute score
/api/v1/agents/bharti/applications/{id}/accommodation-requestRouted to a person; never auto-declined
/api/v1/agents/bharti/requisitions/{id}/analyticsLive stage counts and conversion
/api/v1/agents/bharti/requisitions/{id}/analytics/snapshotImmutable, idempotent per day
/api/v1/agents/bharti/analytics/source-performanceGrouped by the source board every application already carries
/api/v1/agents/bharti/talent-poolCandidates who gave talent-pool consent
/api/v1/agents/bharti/requisitions/{id}/rediscovery-matchesUnordered, with named-field explanations — registered as a POST because scoring the pool is work, not a read
/api/v1/agents/bharti/talent-pool/{id}/re-engageAlways a fresh, per-role consent ask, never assumed
Limits and gotchas
Rejection messages and status updates are drafted and approved locally, and email delivery is real — through this deployment's already-configured ZeptoMail account, not merely wired up. WhatsApp delivery needs its own Meta credential and dry-runs, never claiming sent, until one is added. Offer dispatch is the one channel with no send at all: no e-signature provider is configured, so an approved offer has no route out to a candidate yet.
That is a missing integration, not a missing key — the reason candidate-messaging-and-status-updates and whatsapp-native-blue-collar-hiring stay partial rather than requires_configuration even once WhatsApp and email are both connected.
None has a client in this codebase, so publishing to one is never a fabricated HTTP call against a guessed contract — it records the attempt as failed, naming the gap. The self-hosted careers page is the one channel that is real end to end today.
EPFO/UAN employment history, PAN/Aadhaar-linked identity, and DigiLocker education verification each need an authorised-channel relationship with a licensed intermediary — consent capture and the structural refusal against an automated decision are both real regardless.
What this needs from you
Everything else on this page works with none of these connected.
| Credential | Unlocks |
|---|---|
| Google or Microsoft calendar (OAuth) | Real interview availability. Without it, every held slot is the recruiter's own assertion, not a verified booking. |
| An e-signature provider (DocuSign, Adobe Sign, or an India-compliant equivalent) | Dispatching an approved offer for signature. Drafting and the approval chain work without one. |
| A WhatsApp Business API credential (META_ACCESS_TOKEN, META_PHONE_NUMBER_ID) | Sending a rejection, a status update, a talent-pool re-engagement ask, or a WhatsApp-native session message over WhatsApp. Email already sends for real today through this deployment's configured ZeptoMail account — this credential is what is still missing, not email. |
| A document-AI OCR credential (DOCAI_OCR_API_KEY) | Reading a scanned or photographed resume. A typed or text-layer resume, including prose employment history, already parses without one. |
| A speech-to-text provider | Turning a call recording itself into a transcript. Pasting an existing transcript in and drafting per-category notes from it needs only the already-configured LLM pool. |
| A verified partner relationship with Naukri, LinkedIn, or Indeed | Publishing a requisition to that board. The self-hosted careers-page channel needs nothing and already works. |
| An authorised India BGV provider relationship (EPFO/UAN, PAN/Aadhaar, DigiLocker) | Running an actual background check. Consent capture and the structural refusal against letting a result decide anything are both real without one. |
Questions
No. A stage-transition row that is adverse and not made by a named human is refused by a database CHECK constraint, not just an API rule, so even a direct database write would hit the same wall. Automated rules — a stale-application flag, for instance — may surface a candidate for review; they cannot move a candidate to an archived or rejected stage themselves.
No. POST /rejection-messages/{id}/send only works from a message whose status is already approved, and only once — the human roundup decision and the named approval both have to exist first; there is no path from a score, a stale-application rule, or an automated stage move to a send. Once that gate is cleared, email delivers for real today through this deployment's configured ZeptoMail account. WhatsApp needs its own Meta credential and dry-runs, never claiming sent, until one is added.
It computes one, live, summed from stored contributing factors on every read — but there is no stored score column, the number never appears without the factors behind it, and it is never a decision: the response always carries decision: null and decided_by: "human_required". Deciding is a person's job, not a threshold.
No. There is no column for any of it on a candidate, an application, or a requisition — a 17-name list of forbidden attributes is enforced by the extraction pipeline that reads resumes, and a create or update request that tries to add an unrecognised field is refused outright rather than silently dropped.
No. A peer's scorecard is returned redacted — status and submission time only, no scores or comments — until the caller submits their own. The lock is keyed on who the kit expects to score, so an interviewer cannot unlock peers by never starting a scorecard, and cannot remove themselves from the expected-interviewer list either — that edit is refused too.
It cannot — there is no prior-salary or salary-history column anywhere in the schema. An offer is anchored to the role's own pay band; going outside it requires a written override reason, not a reference to what the candidate said they earned before.
The backend is built and tested, but hiring is a regulated domain: before it goes live, someone needs a view on hiring-discrimination exposure — what Bharti may and may not screen on under Indian law — per docs/REQUIRED_FROM_USER.md §4. That review has not happened yet.
Coming soon