← All posts

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.

Vocion Teamvocion-v2.47.5

"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.

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.