Getting started
Data and privacy
What Krinoa stores about a candidate, what leaves for a model provider, and the export, erasure and retention verbs — with the gaps stated plainly.
What is stored#
| Where | What it holds |
|---|---|
| Candidate | Name, email, phone as typed plus its E.164 form, location, LinkedIn and GitHub URLs, timezone, the résumé, its extracted text, a parsed profile (headline, roles, education, skills), WhatsApp consent facts (opt-in and opt-out times, the source of consent), created and updated timestamps |
| Application | Its public id (A-142), stage, source, cover letter, answers to the job's questions, rejection reason, the spam flag and its reason, the current read and its trigger, thread state (reply routing token, last inbound and outbound times, the thread's owner), applied date |
| Reads | One record per read: the model id, rubric version, status, latency, the inputs the model was given (job text, questions and verbatim answers, cover letter, the résumé) and the read it produced |
| Messages | Every email and WhatsApp message either way: direction, status, subject, body, attachments, and the raw .eml of inbound mail |
| Interviews | Rounds, proposed and booked times, timezone, RSVP state, outcome, and the candidate's scheduling token |
| Team notes | Scorecards, comments and prep notes about the candidate — the team's own material, not the candidate's |
| Activity ledger | One entry per event: kind, actor, timestamps, references to the job, application and candidate, and a small payload |
| Quarantine | Inbound messages that could not be matched to an application, with their addresses, text and raw payload |
Two things are not stored with an application: the applicant's IP address, which is hashed before it is used as a rate-limit key and is not written to the application, and any account for the candidate — applying creates no login. A candidate may separately create a Krinoa account to track the applications they have sent; it is an account with Krinoa, not with your workspace, and nothing about it is visible to you. See The candidate's page below.
A résumé is accepted as PDF or DOCX up to 5 MB; text extraction reads PDFs only, and a PDF's text layer is extracted once. Applying again with a new file replaces the old one, and the superseded file is deleted. GET /api/applications/{ref}/resume is the only way to the bytes, and there is no permanent public URL: the route runs the same visibility check as the packet, so an interviewer who is not assigned to the application gets the packet's own 404, and every access writes a resume.viewed ledger entry — at most one per person, per application, per 24 hours.
What leaves for a model provider#
With no screening model configured, nothing leaves for a model at all: the product works unscored, and the AI-use notice is not shown. Screening covers choosing one.
With a model configured, each read sends the provider:
- The job's title, description and requirements.
- Every custom question and the candidate's verbatim answer.
- The cover letter.
- The résumé — normally as a PDF file part, otherwise as its extracted text capped at 100,000 characters.
- On a re-read, the previous read plus whatever new evidence triggered it, including candidate replies capped at 4,000 characters each.
The whole prompt is capped at 150,000 characters, and what was cut, what was sent and what came back are all recorded with the read. One additional call per new résumé parses the candidate profile from the same material.
Three other services see candidate data, each only if you connect it:
| Service | What it receives | When |
|---|---|---|
| Your email provider | The candidate's address and the message body | Every email your workspace sends or receives |
| Meta (WhatsApp Cloud API) | The candidate's phone number and the message | Only with a connected number and the candidate's opt-in |
| Cloudflare Turnstile | The challenge token and the client IP | Only when the captcha is enabled; the candidate's browser also loads the challenge script |
The apply form tells the candidate that AI helps review applications and that a person makes every decision, and the confirmation email repeats it. Every confirmation email — with or without a model configured — also ends with "To see or delete what you've shared, reply to this email" and a link to the candidate's own page; all of it is appended by the product, so it cannot be edited away. Every apply form also says: "You can ask {Org} to see or delete what you've shared, at any time."
The candidate's page#
A candidate can create a Krinoa account at /me and see the applications they have sent — to your workspace and to every other workspace on Krinoa. The account belongs to the candidate, not to any workspace: there is no password, only a six-digit code mailed to the address, and verifying that code is both the sign-up and the sign-in.
The match is the email address, and only the email address. A card appears on somebody's page when a workspace holds a candidate record whose address equals the one they verified. Nothing links an account to a candidate record: erase a person and their card disappears; correct a person's address and the record follows whoever can prove the new one.
Per application, a candidate sees the workspace's name and logo, the job title, the date they applied, one status word, any interview still waiting for them with the same scheduling link they were mailed, the file name of their résumé, and their own contact details. The status is one of eight words, and none of them is one of your stage names:
| They see | When |
|---|---|
| Received | Applied |
| Under review | Screening — and a rejection you chose not to email |
| Interviewing | Interview |
| Offer | Offer |
| Hired | Hired |
| Not moving forward | Rejected and the decision email reached sent or delivered |
| Withdrawn | They withdrew it themselves |
| Position closed | The job is closed and their application was still open (or was silently rejected) |
After 14 days with nothing visible changing — no stage move, no message from you — a received or under-review card adds one line saying so. Fourteen days is fixed in this version, not a workspace setting.
What the page does not show, at any status: your stage names, the read and its score, summary or findings, the spam flag or its reason, the rejection reason, who is assigned, thread ownership, comments, scorecards, prep notes — or anything at all about another workspace. No workspace can see another's card, and no count of a candidate's other applications reaches a recruiter.
What they can do. Edit their own phone, location and links on one application; replace their résumé (the old file is deleted and, if you have a screening model, the application is queued for a fresh read); and withdraw, which moves the application to the terminal withdrawn stage, cancels any interview that has not happened, and rings your bell. Withdrawing is not a rejection and is not counted as one. The team cannot move an application into withdrawn; it is a record of something the candidate said.
Export and delete, per workspace. The Download button hands them the same document krinoa candidates export produces, with their résumé as a separate download. The Delete button, behind a typed confirmation, runs the same erasure krinoa candidates erase runs — same cascade, same redactions, same file deletion. Both write the ledger entry you would expect, with no actor and reason: "self", so your audit trail can tell "the subject did this" from "somebody here did". Each employer is deleted separately; one button does not reach another workspace.
Deleting the account is not deleting the applications. Closing a Krinoa account removes the account and its profile and nothing else; every workspace keeps its own copy of every application, under its own retention window. The reverse also holds: retention erasing a terminal application makes its card disappear from the candidate's page, because the application it described is gone.
Product analytics#
Product analytics is off until you set NEXT_PUBLIC_POSTHOG_KEY. Unset — the default — no analytics script is loaded in any browser, nothing leaves the server, and what breaks is written to Krinoa's own server log and stops there.
Set, the key sends to PostHog, and what is sent depends on which surface the page is:
| Where | What is sent |
|---|---|
| The signed-in app and every door into it — sign-in, sign-up, the device-code page, an invitation or verification link, a password reset | Pageviews, browser and server errors, and one event per activity-ledger entry — its kind, the workspace, and the ids of the job, application or candidate it refers to. The teammate is identified by their user id, name and work address, and grouped by workspace (its name and slug) |
| The landing page and these docs | Anonymous pageviews and errors, held in memory for the tab. No cookie, nobody identified |
| Every candidate-facing surface — a careers page, an apply form, the embed, a scheduling link, the public API | In the browser, nothing: the script is not loaded at all. On the server, what an application causes is still a ledger entry, so its event (activity:application.received, with the application's and the candidate's internal ids) is sent like any other, and a server error on one of these pages is reported with the page's path |
Two rules hold wherever it does run. Autocapture is off, so no clicked element, typed value or line of page text is ever collected — the screens that show candidate data send only the fact that they were opened — and session recording is off with it. And an event's properties are ids: a candidate's name, address, phone number, résumé or message body is not a property of anything, on any tier.
The browser talks to /ingest on your own origin, which the app proxies to PostHog, so a blocked third-party domain changes nothing about what is or is not collected.
Export#
candidates:export is an owner and admin capability, works from an API key, and writes a candidate.exported ledger entry. The candidate can also produce the same document themselves from their own page, which ledgers the same kind with no actor and reason: "self".
krinoa candidates export 3f1c0d2e-0000-4000-8000-abcdef012345 --out ./dsarWithout --out the command prints the document; with it, the directory gets candidate-<id>.json and the résumés it references. The same document is at GET /api/v1/candidates/{id}/export, and it holds:
- Per candidate — their fields and WhatsApp consent facts.
- Per application — the job, stage, source, dates, answers, cover letter, rejection reason, the current read, every message on either channel, every interview, the stage history, and a path into the access-checked résumé route rather than a public link.
- Not included — scorecards, comments, prep notes and internal assignees: the team's working notes about a decision, not the subject's data.
Erasure#
candidates:erase is an owner and admin capability, works from an API key, and is irreversible; the CLI refuses without --yes and prints what would be erased first. The subject can run the same erasure on themselves from their own page, behind a typed confirmation; it takes this exact path and is ledgered with no actor and reason: "self".
krinoa candidates erase 3f1c0d2e-0000-4000-8000-abcdef012345 --yesThe same verb is DELETE /api/v1/candidates/{id}, and the packet's "…" menu has Erase this person… behind a confirmation that names them and counts their applications and files. It deletes the candidate and cascades their applications, assignments, interviews, scorecards, comments, notifications and messages; redacts quarantined mail from their address or number; and removes every stored file those rows pointed at — résumés, attachments, raw .eml archives and WhatsApp media. File deletion is durable rather than best-effort: every key is recorded in the same transaction as the record deletes and retried until the bytes are gone. Two narrower verbs use the same machinery — krinoa apps delete A-142 --yes deletes one application and its files, taking the candidate with it when it was their only one, and deleting a job erases the candidates whose only application was to that job.
Nothing that carries the person's words, name, address or number survives. Three anonymized records remain:
- The activity ledger keeps each entry's kind, actor and timestamp, nulling the references and rewriting every payload key that can carry personal data to
[erased]. Onecandidate.erasedentry records counts and no name. - A read keeps its model id, rubric version, status, latency, timestamp and score and is stamped redacted, with the job text, questions and answers, cover letter and the model's prose nulled. Opening it answers "This read was redacted".
- A quarantined message keeps only its message id, reason and resolution, so a redelivery of the same message dedupes instead of re-creating the person.
Retention#
Retention is one setting per workspace, at Settings › Data › Retention, and it is off by default: with it unset, nothing is deleted automatically.
krinoa org retention set --months 24krinoa org retention set --months noneThe window is a whole number of months from 1 to 120, or none. When it is set, a scheduled cleanup erases candidates who meet all of these: every one of their applications is terminal (rejected, hired or withdrawn); their last touch — the most recent of their application date, the last message either way, and the last interview's end — is older than the window; and nobody in the workspace has a live application for them. Those erasures take the same path as a manual one, ledgered with reason: "retention" and no actor.
The same cleanup clears five other things on fixed windows, whatever the retention setting says:
| What | Window |
|---|---|
| A read's candidate text — job text, questions and answers, cover letter, the model's prose | Redacted at four years; the anonymized record is kept indefinitely, the candidate's words for four years at most |
| Resolved quarantine rows | 30 days |
| Read notifications | 90 days |
| Interview scheduling tokens | 30 days past the interview |
| Expired invitations, sessions and verification rows | Their own expiry |
What is not built#
A candidate can serve themselves from their own page. A request that arrives instead as a reply to the confirmation email lands in the Inbox as needs-reply on that candidate's thread; the team serves it with krinoa candidates export and krinoa candidates erase, or the same two verbs from the packet. Both work from an API key and both write their own ledger row, so a request handled by an agent is as auditable as one handled by a person — see the CLI. The gaps, stated plainly:
- No per-workspace privacy policy URL. The careers page cannot link to your own policy. If you need one in front of candidates, put it in the job description or the About text.
- No workspace deletion and no user account deletion verb. Comments and scorecards already survive a missing author, rendering as "a former teammate".
- Retention is one window per workspace. There is no per-job override.
Updated 2026-09-16