Recruitment CRM
Create a JD, add candidates with résumés, send each one a secure interview link over WhatsApp, and get back a scored interview — with a funnel report per role.
Reviewed & approved · install free · connect your tools
Last updated 2026-08-03
Screenshots

About Recruitment CRM
Recruitment CRM
Create a JD, add candidates with résumés, send each one a secure per-candidate interview link over WhatsApp, and get back a scored interview — with a funnel report per role.
Built on the Telenow App Platform. Forked from marketplace-apps/instant-screen, which
covers the inbound-applicant screening case; this app covers the outbound, scheduled,
JD-driven interview case.
Full design: RECRUITMENT_CRM_APP_PLAN.md.
WhatsApp templates: RECRUITMENT_CRM_WA_TEMPLATES.md.
Status
| Built | Not yet |
|---|---|
| 4 objects with relations, views, semantic search | Video capture (#29–31) |
| Jobs list + JD create/edit; closed roles leave the picker | Automatic reminder sweeps — blocked (#26 ← #47) |
| Candidates list, status filters, search, owner + "Mine" | Retention + candidate deletion (#43) |
| Résumé upload, text extraction, LLM summary | End-to-end test call against a real org (#25) |
| Question generation + per-candidate editor | Meta template submission, Cloud API only (#32) |
Tokenized single-use links + /l/:token page | Second round / handoff to a human interviewer |
| Re-invite, replacement links, reschedule, manual reminders | A close-out on a Cloud channel (needs a template) |
| Bundled interview agent + one-click setup | |
| WhatsApp send (web + Cloud) and email, with delivery log | |
| Close-out message to rejected candidates | |
| Recruiter notes per candidate | |
| Screening — scorecards, competency charts, decision queue | |
| Reports — funnel, by source, CSV export | |
| CSV import with column mapping and a validation preview | |
| CSV export on Jobs, Candidates, Screening and Reports | |
| Read-only shortlist link for a hiring manager | |
| Dashboard — funnel, trend, recent interviews |
Project layout
The UI is one bundle serving every manifest page. ui/App.tsx is a router and nothing
else; the pages own their own data reads.
ui/
index.tsx mount(App)
App.tsx hasTelenow guard → ToastHost → page switch
lib/
ui.tsx the design system: Page, Card, Btn, Field, Modal, Chip, toasts…
palette.ts every colour in the app, as CSS custom properties
charts.tsx funnel, histogram, trend, split bar, radar, sparkline — hand-drawn SVG
types.ts the four objects + the pipeline model (RANK, STATUS_META, FUNNEL)
helpers.ts formatting, CSV read/write, validation, the org-roster lookup
bridge.ts feature-detected access to ai / links / members / settings.set …
telenow-ext.d.ts ambient types for the bridge namespaces SDK 0.4.0 predates
scorecard.tsx the one rendering of an interview result, shared by two pages
invite.ts minting links, and the WhatsApp / email wording
resume.ts upload, extraction, LLM fact extraction
questions.ts the question bank, merging and generation
candidateImport.ts CSV column matching, coercion and per-row validation
workspace.ts useWorkspace() — install config + RBAC gates in one hook
pages/
Dashboard.tsx Jobs.tsx Candidates.tsx Screening.tsx Reports.tsx Setup.tsx
candidates/ AddCandidate.tsx CandidateDetail.tsx QuestionsEditor.tsx ScheduleInvite.tsx
lib/bridge.ts exists because the live dashboard injects namespaces (ai, links,
members, settings.set, agents.setPublic, calls.open, whatsapp.sendTemplate,
files.extract) that the published SDK typings do not declare and the dev mock does not
have. Reading one straight off the bridge throws a synchronous TypeError that no
.catch() on the promise ever sees — which white-screens the page. Every accessor there
either returns the capability or throws a message written for the person looking at it.
Screening
Candidates answers where is this person; Screening answers who should we talk to, and is the screen working. It reads every interview that carries a score and shows:
- Score distribution in ten-point bands, with the pass mark drawn where it actually falls — but only when every interview in view shares one, since with several roles selected a single line would be true of none of them.
- What the agent recommended, as a share — the recommendation is advice; the pipeline only moves when a person shortlists or rejects.
- Strengths and gaps by competency, weakest first. This is the reason
interview.per_competencyis stored at all: a must-have averaging 30 across forty candidates is not forty weak candidates, it is a requirement the market cannot supply. - A decision queue — filter to Needs a decision for everyone interviewed, scored, and not yet shortlisted or rejected.
Opening a scorecard gives the full result plus a radar of that candidate's competency profile against the role's pass mark, drawn from three competencies up (below that a radar degrades into a line and misleads, so the bars stand alone).
Importing candidates
Candidates → Add candidates → Import a CSV. Built for what recruiters are actually sent: exports from Naukri, LinkedIn and hand-maintained spreadsheets, which agree on almost nothing.
- Columns are matched by name. "Phone", "Mobile", "Mobile No." and "Contact Number" all find the phone column; anything matched wrongly can be repointed by hand.
- A header row is detected, not assumed. A file with no header is common (someone pasted a column out of a bigger sheet), and treating a real candidate as the header loses them.
- Phones are coerced, not rejected.
9876500021,09876500021and+91 98765 00021all become E.164, and the preview shows what each one was. Anything ambiguous is left exactly as typed so validation still catches it. - Everything is shown before anything is written: which rows will be added, which are broken and why, and which already exist. Duplicates are matched on the last ten digits, because importing the same person twice gives them two links — whichever they open, the other looks like a no-show.
The parser is hand-rolled rather than split(','): quoted cells containing commas, embedded
newlines, doubled quotes and CRLF all appear in real files, and splitting on commas mangles
all four silently.
Sharing with a hiring manager
The person who decides is usually not a Telenow user. Reports → Share with a hiring
manager mints a page link: a read-only summary of the interviewed candidates, built
server-side at mint time, so the recipient cannot widen the list, change the filter, or
reach a candidate who wasn't shared. Not single-use (an HM will open it twice) — it expires
after 72 hours instead.
Export CSV covers the other half: it exports exactly what the filters select, quotes
everything, and prefixes any cell starting with =, +, - or @ with an apostrophe so
Excel doesn't execute a candidate's name as a formula.
Needs migration 0221_app_public_links.sql applied, or minting a link fails at the
API. Two known gaps: an email-only invite has no channel to deliver the access code on,
and a WhatsApp web send returns no wamid, so its delivery status can't be tracked.
Link actions, and why there are three
A single-use link plus a fallible messaging channel means "issue a link" happens more than once per candidate. The row offers three actions and they are not interchangeable:
| Action | When | Effect on the funnel |
|---|---|---|
| Invite | status added | creates the first interview row |
| New link | invited / sent / scheduled | replaces the link on the existing row — same attempt |
| Invite again | any terminal status (no_show, expired, declined, closed_early, completed, evaluated, shortlisted, rejected) | creates a second interview row; the first keeps its score |
A reschedule is "New link with a different slot", and it is detected rather than chosen: the link's opening time is baked in server-side at mint, so moving a slot always needs a new link. When the slot changes, the panel sends the moved wording instead of a fresh invitation — the candidate has already had one of those.
The distinction is the whole point. A candidate who no-showed and was re-invited is one candidate with two scheduled attempts; a candidate whose link was replaced is one attempt. Collapsing them either inflates "scheduled" or loses the first score — and the recruiter is the one who has to explain the numbers.
All three mint first and revoke the old link after the new one is recorded: minting can fail, and a candidate left holding nothing is worse than one holding a stale link. If the revoke then fails, the panel says so explicitly rather than implying the old link is dead.
Reminders are manual. The automatic 1-hour / 10-minute sweep needs a platform primitive that doesn't exist yet (#47), so the row has a Remind button that sends the reminder wording with the same link. The scheduling field used to claim reminders went out automatically; it no longer does.
Choosing the interview agent
Setup has a live picker of the org's agents. Choosing one — or pressing Create the interview agent to build this app's own — does three things in a single action:
- builds the agent from the bundled
interview-agentmanifest template, when creating (auto-bound to this app's tools and knowledge base), - publishes it and reads back its public slug,
- writes
interview_agent_id+interview_agent_sluginto app settings, which is what the read-only ID and slug fields on the page are showing.
Neither field is ever typed by hand. A mistyped UUID produces an interview link that opens onto nothing, and the candidate is the one who finds out.
A link resolves by public slug, never by agent id. An agent that is chosen but not published looks finished and cannot open a single interview — so that state gets a warning on Setup, a Publish this agent button next to it, an incomplete checklist row, and its own message on Candidates where invites are switched off. Do not unpublish the agent in Agents: it kills every link already sent.
Switching agents is just re-picking from the list. The id and the empty slug are written together, so no link can be minted against a new agent while the previous agent's slug is still on file. Only interviews started after the switch use the new agent.
Nothing about a specific job or candidate lives on the agent. The questions, role, must-haves, résumé summary, language and time budget travel with each candidate's link and are handed over when the candidate opens it — so one agent serves every JD and every candidate, and the org user only ever adds JDs.
The agent id is written before publishing, so a failure at step 2 (a non-admin user, say) leaves a state where pressing the button again finishes the setup rather than creating a second, orphaned agent.
Requires owner or admin — the platform gates both saving app settings and publishing an agent on role, not on permissions.
Data model
| Object | Holds | Notes |
|---|---|---|
job | the JD | jd_text feeds question generation; must_haves drives early close |
candidate | the person | relation → job; semantic: true so ?search= ranks by meaning over resume_summary |
interview | one row per attempt | relations → candidate + job; session_id joins to the platform call |
message_log | send audit | answers "did we actually send the link?" — the first question on a no-show |
interview is deliberately separate from candidate: reschedules and second attempts
corrupt a single-row model, and the funnel needs scheduled and taken as independent
counts.
Pipeline
added → invited → sent → scheduled → started → completed → evaluated
├─ shortlisted
└─ rejected
↘ declined ↘ expired ↘ no_show ↘ closed_early ─────┘
Off-ramps carry the rank of the furthest stage actually reached (RANK in ui/lib/types.ts),
so a no_show counts toward scheduled but never toward taken. closed_early counts
as taken — the candidate did the interview, it just ended early — and is terminal but
scorable, not a rejection.
No sample data
The app ships with none, and seeds nothing on install. A fresh install is empty and every row is created through the UI, so a real pipeline is never mixed with demo rows a recruiter then has to identify and delete.
Local development
npm install
npm run validate
node node_modules/telenow/bin/telenow.mjs dev --port 5179
The dev mock bridge deliberately has no links, ai or members, so the degraded
paths are the ones you exercise locally: Invite reports that secure links are unavailable,
"Generate questions" is disabled with the reason on it, and owner names render as nothing
rather than as "Someone who has left".
Mock data persists in localStorage under telenow.dev.data.recruitment-crm — clear that
key to start from an empty store again.
Typecheck (esbuild bundles without typechecking, so run this separately):
./node_modules/.bin/tsc -p tsconfig.json
★ WhatsApp needs scopes from BOTH planes
WhatsApp is enforced in two different layers under two different names, and this app
touches both — so it declares whatsapp:send and whatsapp/whatsapp:templates:
| Call | Plane | Scope |
|---|---|---|
| "Send link" button on the Candidates page | UI bridge (AppPageHost.tsx) | whatsapp:send |
Poller / backend POST /api/app-whatsapp/send, /send-template | app-key REST (Rust) | whatsapp |
| Naming an approved Meta template | app-key REST | whatsapp:templates |
Declaring only one silently fails on the other plane with a runtime 403 — there is no
server-side scope whitelist, so nothing catches it at upload. Workflow send-message
steps are a third case: they do no scope check at all and are authorised by the install.
See /docs/app-scopes → WhatsApp uses two different scope names.
★ Copy buttons need clipboard-write delegated
App pages run in a sandboxed iframe on an opaque origin, where Permissions Policy
defaults clipboard-write to self — so navigator.clipboard.writeText() rejects unless
the host iframe carries allow="clipboard-write" (it now does). CopyButton in
ui/lib/ui.tsx still falls back to a execCommand('copy') textarea, and if that fails too
it reveals the text in a selectable field rather than doing nothing. A copy button that
silently no-ops is worse than none — the recruiter pastes whatever was already on the
clipboard into WhatsApp and sends it.
Ready-made agents it ships
Runs each candidate's approved question list as a structured voice interview in their own Indian language, closes early and gracefully when they clearly do not meet a hard requirement, and returns a scored English scorecard against the role's must-haves.
What your agents can do with it
Open the record for this attempt. Call this ONCE, the moment the candidate agrees to begin and before you ask question one — never later. It creates the row the hiring team sees, so an interview that is abandoned thirty seconds in is still recorded as an attempt that happened. A candidate can hang up, lose signal or close the tab at any second and you get no turn to react, so anything left until the end is simply lost. It returns nothing you need to read out; it is silent bookkeeping.
Close the record for this attempt and file your judgement of it. Call this ONCE, when the interview ends — whether it ran to the end or you stopped it early — after start_interview_attempt has already opened the record. Score what you ACTUALLY heard, not what you expected to hear: an interview that stopped after one answer is scored on that one answer, and a low score is the correct result for it. It is silent bookkeeping: never mention it, never read it aloud, never tell the candidate their score or whether they passed, and never let calling it interrupt the conversation.
Data it manages
Permissions it requests
Apps run inside your workspace under least-privilege scopes you can see up front. This app asks for:
objects:jobobjects:candidateobjects:interviewobjects:message_logagents:readagents:writecalls:readai:llmfiles:readfiles:writelinks:readlinks:writeconnection:googlehttp:gmail.googleapis.comwhatsapp:sendwhatsappwhatsapp:webwhatsapp:templatesmembers:readuser:profilePublisher
Marketplace publisher · app reviewed by Telenow before listing.
Your data, your keys
Apps run inside your own workspace under least-privilege scopes. Telenow is the control plane — the AI, telephony and data stay on accounts you own.
Secrets, carrier credentials and API keys stored AES-GCM encrypted; TLS in transit; HMAC-signed webhooks.
Strict per-organization data isolation with role-based access for every member.
Every mutating action is logged and filterable, with CSV export for your records.
In-app DLT / regulatory flows, TCPA calling windows, and org-wide Do-Not-Call enforcement.
More recruitment apps
Screen every job applicant with a structured call in 60 seconds and hand recruiters a ranked shortlist.
Install Recruitment CRM
Sign up free and get $2.99 in credit — no card required. Connect your number, pick a template, and go live in minutes.
