AI Applicant Screening With a Human Review Gate
An agent can extract and score an application, but only a person should reject one. Here is the object, the gate, and the API call that enforces it.
"AI screens applicants" usually means a model scores a resume and something acts on the score.
Vocion's actual mechanism is narrower and domain-free by design: an agent extracts fields from an
application into a typed object — the object type ("Applicant" or whatever the workspace calls
it) is authored by the workspace, not hardcoded — and proposes it through a single generic
action, objects.propose_candidate. That action is marked external: true, so it always lands in
the unified review queue rather than executing on its own. A person decides: approve advances the
candidate, decline holds it. Nothing about hiring is special-cased in the framework — the same
action proposes an event listing or a grant deadline in a different workspace.
This is for an engineer or HR-ops lead who has been pitched an "AI screens resumes" tool and wants to know what a self-hostable version looks like mechanically: what object the extracted application becomes, who is allowed to approve or decline it, and how a below-threshold case is held instead of silently dropped.
What does "AI screens applicants" actually risk, mechanically?
The failure mode people are afraid of is an auto-rejection with no human in the loop: a model misreads a resume, or a scoring rule has a bias nobody caught, and a real candidate is declined without anyone looking. Two properties of the mechanism prevent that here, and neither is a policy statement — both are in the code path.
First, objects.propose_candidate is declared external: true in its action definition
(packages/core/src/libs/actions/objects-propose-candidate.ts), which puts it under the autonomy
gate that holds every external-effect action for review. Second, it is not on any workspace's
trust.yaml allowlist by default — the registered action ids a workspace can list there are a
fixed, small set (docs/entities/trust.md: gmail.send, hubspot.update,
discovery.review_proposal, personalization.enroll, qc.hold, qc.release,
qc.request_rework, dataset.add_example), and objects.propose_candidate is not among them. So
nothing short of a workspace explicitly adding a new trust rule for it could ever let a proposal
skip review — and even then, trust.yaml's autoApproveAbove only gates auto-approval.
Declining a candidate is always a human act; there is no autoReject anything to configure.
What object does an application become?
An extracted application becomes a business_object, an instance of an object type the workspace
authors at objects/<slug>/type.yaml (docs/entities/object-type.md). The type declares a JSON
Schema for the record's metadata, which sources matter most when retrieving for it
(sourceRelevance), and how material gets classified into it (classificationPromptFile,
fewShotExamples). Nothing in vocion-core ships an Applicant type — a workspace would author
one the same way the docs show a discovery_call type:
# objects/discovery_call/type.yaml — the pattern, not a hiring example
slug: discovery_call
label: Discovery Call
description: A first substantive sales conversation with a prospect.
icon: phone
classificationPromptFile: classification-prompt.md
schema:
type: object
properties:
account: {type: string}
stage: {type: string}
next_step: {type: string}
sourceRelevance:
zoom: 2.0
gmail: 1.0
fewShotExamples:
- input: 45-minute Zoom with a new mid-market prospect, needs and budget discussed
output: discovery_call
label: Clear first substantive conversation.
An applicant type would swap in fields like role, email, and whatever the workspace extracts,
and its own classification prompt. The schema is the contract; the review card is rendered from
it, not from anything hiring-specific.
How does the agent propose a candidate instead of just writing one?
objects.propose_candidate's own file-header comment lays out the lifecycle plainly. Propose
creates the business_object row immediately, with status: 'candidate', holding the whole
extracted payload — so a rejected extraction is still a queryable, auditable row, not lost inside
an action's JSON once the decision is made. Approve flips that row to approved and, when the
approving caller passes one, stamps an external system's id onto it in the same call. Reject flips
it to rejected and keeps the payload — that row is the record of what the extractor got wrong,
not a deletion.
The three real values of CANDIDATE_STATUS in that file are proposed: 'candidate',
approved: 'approved', and rejected: 'rejected'. There is no fourth value for "held" or
anything similar; the field list is exactly those three states.
Dedup runs per candidate, not per page: dedupOn names the fields that identify the thing (an
applicant's email and the role they applied for, say), so a re-walked source refreshes the one
pending queue item instead of stacking a second copy of the same application.
Who is allowed to decide, and what does a decision look like over the wire?
Every proposal — screening or otherwise — lands in the same place: ReviewService
(packages/core/src/services/ReviewService.ts), whose header comment states the point directly:
"ONE review queue across the planes." A screening decision is not a special code path; it is one
row of kind action alongside paused workflows and paused missions (ReviewKind:
'workflow' | 'mission' | 'action').
A recruiter or an internal tool reads the pending queue with GET /api/v1/reviews and decides an
item with POST /api/v1/reviews/decide, body { kind, id, action, reason?, editedInput?, externalRef? }, where action is approve or reject. externalRef is how a caller that just
created the downstream hire record in its own ATS links the two in the same call the decision is
made in — core never writes to that system itself.
What does "held, never auto-rejected" actually mean as a designed property?
A retail hiring workflow — screener and router, human approves each step, below-threshold
applicants held rather than auto-rejected — is one shape this mechanism supports. "Held" is not a
status the framework invents for hiring: it is a workspace convention layered on top of the three
real CANDIDATE_STATUS values, using an ordinary workflow step. A workspace can add an approve
step after the candidate is proposed whose prompt asks the recruiter to confirm before the
candidate advances to the next stage — the candidate object itself just sits at status: 'candidate' until someone decides. Because the action is external: true regardless of how
confident the extraction was, even a low-confidence proposal stops in the queue the same way a
high-confidence one does. Nothing here does bias or compliance scoring, and nothing here claims to
— Vocion supplies the review gate; a human decides every decline.
Where would a screener + router workflow gate this, concretely?
Illustrative only — this file does not ship in the repo — using the four real step types from
docs/entities/workflow.md (approve, ask, action, sync; the doc states plainly, "there is
no skill step"):
# workflows/hiring-screen/workflow.yaml — illustrative, not a file that ships in the repo
slug: hiring-screen
name: Application Screen and Route
agent: hiring-lead
trigger:
type: event
event: object.created
steps:
- name: refresh-applications
type: sync
sources: [gmail]
- name: propose-candidate
type: action
action: objects.propose_candidate
input:
objectType: applicant
title: '{{trigger.title}}'
fields: '{{trigger.fields}}'
dedupOn: [email, role]
- name: review-candidate
type: approve
prompt: Approve this applicant to advance, or decline to hold them.
reviews: propose-candidate
The approve step is what pauses the run in the review queue; objects.propose_candidate is what
put the row there in the first place. Both gates are real and independent — the action's own
external: true flag, and the workflow's approve step — which is why a workspace cannot
accidentally wire around either one.
The runnable building block
There is no shipped, public demo of this exact workflow to run end to end. What is real and
runnable is the generic building block underneath it. Any reader can author an object type per
docs/entities/object-type.md's schema — objects/applicant/type.yaml in their own workspace —
and call objects.propose_candidate against it. Deciding the resulting queue item is a real,
copy-paste request against the same endpoint any paused workflow or mission uses:
curl -s -X POST \
-H "Authorization: Bearer $VOCION_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"kind":"action","id":<id>,"action":"approve","editedInput":{"role":"Store Associate"}}' \
http://localhost:3000/api/v1/reviews/decide
Swap "action":"approve" for "action":"reject" to hold the candidate instead — editedInput is
ignored on reject, and the payload stays on the row either way.
How Vocion does this
The mechanism described above is entirely generic — it is documented at
/docs/features/actions, the write half of the runtime that every
external-effect action, screening included, runs through.
Related
Read AI Agent Approval Workflow: How to Build One for the approve-step and trust mechanics this piece leans on. Read What an AI Agent's Autonomy Level Actually Gates for why this stays gated even at a higher autonomy level.
Vocion is an open-source agent workforce platform: clone vocion-core
(https://github.com/vocion/vocion-core) and author your own object type to try this against a
workspace of your own.