recruitment

Recruitment CRM

by Telenow AI

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.

recruitmentNative

Reviewed & approved · install free · connect your tools

1
Installs
1.0.3
Version
2
Tools

Last updated 2026-08-03

Screenshots

Recruitment CRM screenshot 1

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

BuiltNot yet
4 objects with relations, views, semantic searchVideo capture (#29–31)
Jobs list + JD create/edit; closed roles leave the pickerAutomatic reminder sweeps — blocked (#26 ← #47)
Candidates list, status filters, search, owner + "Mine"Retention + candidate deletion (#43)
Résumé upload, text extraction, LLM summaryEnd-to-end test call against a real org (#25)
Question generation + per-candidate editorMeta template submission, Cloud API only (#32)
Tokenized single-use links + /l/:token pageSecond round / handoff to a human interviewer
Re-invite, replacement links, reschedule, manual remindersA 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_competency is 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 candidatesImport 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, 09876500021 and +91 98765 00021 all 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.

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:

ActionWhenEffect on the funnel
Invitestatus addedcreates the first interview row
New linkinvited / sent / scheduledreplaces the link on the existing row — same attempt
Invite againany 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:

  1. builds the agent from the bundled interview-agent manifest template, when creating (auto-bound to this app's tools and knowledge base),
  2. publishes it and reads back its public slug,
  3. writes interview_agent_id + interview_agent_slug into 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

ObjectHoldsNotes
jobthe JDjd_text feeds question generation; must_haves drives early close
candidatethe personrelation → job; semantic: true so ?search= ranks by meaning over resume_summary
interviewone row per attemptrelations → candidate + job; session_id joins to the platform call
message_logsend auditanswers "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:

CallPlaneScope
"Send link" button on the Candidates pageUI bridge (AppPageHost.tsx)whatsapp:send
Poller / backend POST /api/app-whatsapp/send, /send-templateapp-key REST (Rust)whatsapp
Naming an approved Meta templateapp-key RESTwhatsapp: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-scopesWhatsApp 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

JD Interview Agentmulti-step flow

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

start_interview_attempt

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.

save_interview_outcome

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

Job Candidate Interview Message log

Permissions it requests

Apps run inside your workspace under least-privilege scopes you can see up front. This app asks for:

Store & manage “job” records in your workspace
objects:job
Store & manage “candidate” records in your workspace
objects:candidate
Store & manage “interview” records in your workspace
objects:interview
Store & manage “message_log” records in your workspace
objects:message_log
Read your agents' configuration
agents:read
Read your agents' configuration
agents:write
Read calls of agents this app is connected to
calls:read
ai:llm
ai:llm
files:read
files:read
files:write
files:write
links:read
links:read
links:write
links:write
Use your connected google account
connection:google
Make network requests to gmail.googleapis.com
http:gmail.googleapis.com
Send WhatsApp messages
whatsapp:send
Send WhatsApp messages
whatsapp
Send WhatsApp messages
whatsapp:web
Send WhatsApp messages
whatsapp:templates
members:read
members:read
See your name and email
user:profile

Publisher

Telenow AI

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.

Encryption everywhere

Secrets, carrier credentials and API keys stored AES-GCM encrypted; TLS in transit; HMAC-signed webhooks.

Org isolation & RBAC

Strict per-organization data isolation with role-based access for every member.

Full audit log

Every mutating action is logged and filterable, with CSV export for your records.

Telephony compliance

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.

recruitment
$2.99 free credit on signup

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.