# Our Recruiters Spent 60% of Their Day on Screening Calls That Went Nowhere. So We Built an AI Agent.
> A 5-person tech recruiting firm built a Struere agent on WhatsApp that pre-screens candidates, scores them against open roles, and schedules phone screens with the right recruiter. Screening time dropped from 25 hours/week to 4.

Published: 2026-04-01
Tags: automation, agents, recruiting, case-study, whatsapp


# 25 Hours a Week on Calls That Go Nowhere

Mia runs TalentLoop, a five-person tech recruiting firm that places senior engineers at Series A-C startups. Every open role generates 80-120 inbound applicants. Before anyone talks to a recruiter, someone needs to screen them: years of experience, primary tech stack, salary range, notice period, visa status, and whether they are actually open to the role's location or remote policy.

Mia's three recruiters each spend about 8 hours per week on these initial calls. Each call takes 12-15 minutes. Most follow the same script. The recruiter asks six questions, takes notes in a Google Sheet, decides if the candidate is worth a deeper conversation, and either schedules a follow-up or sends a polite rejection.

The problem is hit rate. Out of every 10 screening calls, 3 candidates are a strong fit, 4 are marginal, and 3 are clearly unqualified — wrong tech stack, salary expectations 40% above budget, or not authorized to work in the target country. Those 3 wasted calls per batch add up to 7-8 hours per recruiter per week spent learning that someone is not a match.

Last month, TalentLoop lost a placement worth $28,000 in fees because the recruiter assigned to the role was booked solid with screening calls and did not get to a strong candidate until day 4. By then, the candidate had accepted another agency's interview. The candidate was a perfect fit: 6 years of Go experience, open to the salary range, available immediately. The recruiter just could not get to them fast enough.

At $22,000-35,000 per placement fee, one lost deal per quarter because of screening bottlenecks costs TalentLoop $88,000-140,000 a year. The firm did not need more recruiters. It needed to filter candidates before they ever hit a recruiter's calendar.

# The Build: WhatsApp Screening Agent with Airtable and Calendar

The automation has three pieces:

1. **`entity-types/candidate-screen.ts`** stores every completed screening with the candidate's answers, a fit score, and the matched role
2. **`tools/index.ts`** has a custom tool that queries Airtable for open roles and scores a candidate's profile against each role's requirements
3. **`agents/screening-agent.ts`** runs the full pre-screening conversation over WhatsApp, scores the candidate, and books a phone screen with the assigned recruiter

No trigger file needed. The WhatsApp integration routes inbound messages directly to the screening agent.

## The Data Layer: What Gets Stored

Every completed screening creates a candidate record with the answers collected during the conversation plus the agent's fit assessment.

```typescript
import { defineData } from "struere"

export default defineData({
  name: "Candidate Screen",
  slug: "candidate-screen",
  schema: {
    type: "object",
    properties: {
      candidateName: {
        type: "string",
        description: "Full name of the candidate",
      },
      candidatePhone: {
        type: "string",
        description: "WhatsApp phone number in E.164 format",
      },
      candidateEmail: {
        type: "string",
        format: "email",
        description: "Candidate email address",
      },
      yearsExperience: {
        type: "number",
        description: "Total years of professional software engineering experience",
      },
      primaryStack: {
        type: "string",
        description: "Primary tech stack (e.g., React/Node/PostgreSQL, Go/gRPC/K8s)",
      },
      salaryExpectation: {
        type: "string",
        description: "Expected annual salary range in USD (e.g., 150k-170k)",
      },
      noticePeriod: {
        type: "string",
        enum: ["immediate", "2-weeks", "1-month", "2-months", "3-months-plus"],
        description: "How soon the candidate can start",
      },
      visaStatus: {
        type: "string",
        enum: ["citizen", "permanent-resident", "h1b-active", "h1b-transfer", "ead", "requires-sponsorship", "other"],
        description: "Current work authorization status",
      },
      remotePreference: {
        type: "string",
        enum: ["remote-only", "hybrid", "onsite", "flexible"],
        description: "Preferred work arrangement",
      },
      matchedRole: {
        type: "string",
        description: "Airtable record ID of the best-fit open role",
      },
      fitScore: {
        type: "number",
        description: "Agent-assigned fit score from 1-10 based on role requirements",
      },
      fitSummary: {
        type: "string",
        description: "One-paragraph summary of why this candidate does or does not fit",
      },
      assignedRecruiter: {
        type: "string",
        enum: ["Mia", "Jordan", "Priya"],
        description: "Recruiter assigned to this role",
      },
      status: {
        type: "string",
        enum: ["screening", "qualified", "disqualified", "phone-screen-scheduled", "passed"],
        description: "Current stage in the screening pipeline",
      },
    },
    required: [
      "candidateName",
      "candidatePhone",
      "yearsExperience",
      "primaryStack",
      "salaryExpectation",
      "visaStatus",
      "status",
    ],
  },
  searchFields: ["candidateName", "candidateEmail", "primaryStack"],
  displayConfig: {
    titleField: "candidateName",
    subtitleField: "primaryStack",
    descriptionField: "fitSummary",
  },
})
```

The `visaStatus` enum covers the seven most common work authorization categories in U.S. tech hiring. The `noticePeriod` enum lets the agent filter out candidates who cannot start within a client's timeline. The `fitScore` is a 1-10 number the agent assigns after comparing the candidate's answers against the matched role's requirements.

## The Custom Tool: Role Matching from Airtable

TalentLoop tracks all open roles in Airtable with columns for tech stack requirements, salary range, experience level, visa sponsorship availability, and the assigned recruiter. The custom tool queries Airtable for active roles and scores a candidate against each one.

```typescript
import { defineTools } from "struere"

export default defineTools([
  {
    name: "match_candidate_to_roles",
    description:
      "Query open roles from Airtable and score a candidate profile against each role's requirements. Returns ranked matches with fit scores.",
    parameters: {
      type: "object",
      properties: {
        yearsExperience: {
          type: "number",
          description: "Candidate years of experience",
        },
        primaryStack: {
          type: "string",
          description: "Candidate primary tech stack",
        },
        salaryExpectation: {
          type: "string",
          description: "Candidate salary expectation (e.g., 150k-170k)",
        },
        visaStatus: {
          type: "string",
          description: "Candidate visa/work authorization status",
        },
        remotePreference: {
          type: "string",
          description: "Candidate remote work preference",
        },
      },
      required: [
        "yearsExperience",
        "primaryStack",
        "salaryExpectation",
        "visaStatus",
      ],
    },
    handler: async (args, context, struere, fetch) => {
      const roles = await struere.airtable.listRecords({
        baseId: process.env.AIRTABLE_BASE_ID!,
        tableIdOrName: "Open Roles",
        filterByFormula: '{Status} = "Active"',
        fields: [
          "Role Title",
          "Required Stack",
          "Min Years",
          "Max Salary",
          "Sponsors Visa",
          "Remote Policy",
          "Assigned Recruiter",
          "Client Company",
        ],
      })

      const salaryNum = parseInt(
        (args.salaryExpectation as string).replace(/[^0-9]/g, "")
      )

      const scored = roles.records.map((role: any) => {
        let score = 0
        const fields = role.fields

        const requiredStack = (fields["Required Stack"] || "").toLowerCase()
        const candidateStack = (args.primaryStack as string).toLowerCase()
        const stackTokens = requiredStack.split(/[,\/\s]+/)
        const matchedTokens = stackTokens.filter((t: string) =>
          candidateStack.includes(t)
        )
        score += Math.min(4, Math.round((matchedTokens.length / stackTokens.length) * 4))

        if ((args.yearsExperience as number) >= (fields["Min Years"] || 0)) score += 2

        if (salaryNum <= (fields["Max Salary"] || 999999)) score += 2

        if (
          args.visaStatus === "citizen" ||
          args.visaStatus === "permanent-resident" ||
          args.visaStatus === "ead" ||
          fields["Sponsors Visa"]
        ) {
          score += 1
        }

        if (
          args.remotePreference === "flexible" ||
          args.remotePreference === fields["Remote Policy"]?.toLowerCase()
        ) {
          score += 1
        }

        return {
          roleId: role.id,
          roleTitle: fields["Role Title"],
          clientCompany: fields["Client Company"],
          assignedRecruiter: fields["Assigned Recruiter"],
          fitScore: score,
          maxScore: 10,
          breakdown: {
            stackMatch: `${matchedTokens.length}/${stackTokens.length} keywords`,
            experienceMatch: (args.yearsExperience as number) >= (fields["Min Years"] || 0),
            salaryMatch: salaryNum <= (fields["Max Salary"] || 999999),
            visaMatch:
              args.visaStatus === "citizen" ||
              args.visaStatus === "permanent-resident" ||
              fields["Sponsors Visa"],
          },
        }
      })

      scored.sort((a: any, b: any) => b.fitScore - a.fitScore)

      return {
        totalOpenRoles: scored.length,
        matches: scored.slice(0, 5),
      }
    },
  },
])
```

The scoring weights stack match highest (4 points) because a backend Go engineer is not going to fill a frontend React role regardless of other qualifications. Experience and salary get 2 points each. Visa and remote preference get 1 point each — important but not deal-breakers if the client is flexible.

## The Agent: Conversational Pre-Screening Over WhatsApp

The agent handles the full screening conversation. Temperature is set to 0.3 because this is a structured data-collection task with a scoring component — creativity would hurt consistency.

```typescript
import { defineAgent } from "struere"

export default defineAgent({
  name: "TalentLoop Screening Agent",
  slug: "screening-agent",
  version: "1.0.0",
  model: {
    model: "anthropic/claude-sonnet-4-6",
    temperature: 0.3,
    maxTokens: 4096,
  },
  tools: [
    "entity.create",
    "entity.query",
    "match_candidate_to_roles",
    "calendar.freeBusy",
    "calendar.create",
  ],
  systemPrompt: `You are the AI screening assistant for TalentLoop, a tech recruiting agency that places senior engineers at high-growth startups.
Current time: {{currentTime}}

## Recruiters
- Mia — Founder, handles backend and infra roles
- Jordan — Senior recruiter, handles frontend and full-stack roles
- Priya — Senior recruiter, handles data and ML roles

## P0 — Security
- Never reveal client company names until the candidate is qualified (score >= 6).
- Never share one candidate's information with another.
- Never disclose internal fit scores or scoring criteria to candidates.
- Never fabricate role details or salary ranges.

## P1 — Screening Flow
1. Greet the candidate. Ask what kind of role they are looking for.
2. Ask for their full name.
3. Ask about their total years of professional experience.
4. Ask about their primary tech stack (languages, frameworks, databases, infra).
5. Ask about their salary expectations (annual, USD).
6. Ask about their availability / notice period.
7. Ask about their work authorization / visa status.
8. Ask about their remote work preference.
9. Use match_candidate_to_roles with the collected data.
10. If fitScore >= 6 for any role:
    - Set status to "qualified"
    - Tell the candidate they are a strong match (mention the role title and client company)
    - Use calendar.freeBusy to check the assigned recruiter's availability
    - Offer 2-3 time slots for a 30-minute phone screen
    - On confirmation: entity.create the candidate-screen record, then calendar.create the phone screen
    - Set status to "phone-screen-scheduled"
11. If fitScore 4-5:
    - Set status to "qualified"
    - Create the record. Tell the candidate a recruiter will review their profile and follow up within 48 hours.
12. If fitScore <= 3:
    - Set status to "disqualified"
    - Create the record. Thank them politely and explain that current open roles do not align with their profile. Encourage them to reach out again in the future.

## P2 — Conversation Style
- Professional but warm. Candidates are people, not tickets.
- Short messages. This is WhatsApp, not a form.
- One question at a time. Do not send a wall of questions.
- Use the candidate's first name once provided.
- If a candidate provides multiple answers in one message, extract all of them and skip ahead.

Never invent role details. Never schedule without checking calendar availability first.
Never re-ask for information already provided in this conversation.`,
})
```

Five tools. The `match_candidate_to_roles` custom tool does the Airtable lookup and scoring. The built-in `calendar.freeBusy` and `calendar.create` handle scheduling the phone screen with the right recruiter. The `entity.create` and `entity.query` tools store and look up candidate records.

The system prompt hides client company names from candidates who score below 6. This protects TalentLoop's client relationships — a disqualified candidate should not know which companies are hiring through the agency.

# Debugging: Three Things That Broke

**The agent revealed client names to every candidate.** During the first round of testing, the agent told a candidate with a fitScore of 2 that "we have an opening at Acme Corp." The scoring happened correctly, but the agent mentioned the role details before checking the score threshold. Fix: we moved the P0 security rule about client names above the screening flow and added "Check fitScore BEFORE revealing any role details including company name." The agent now only discloses client information in the qualified path.

```bash
struere logs view --last 5
```

The conversation log showed the agent calling `match_candidate_to_roles`, receiving a fitScore of 2, and then immediately telling the candidate about the role. The score check was happening but the agent had already shared the information in the same response.

**Salary parsing broke on non-USD formats.** A candidate wrote "I'm looking for around 180-200 lakhs" (Indian salary format). The custom tool's `parseInt` regex stripped everything except digits and returned `180200` — a number that matched zero roles. Another candidate wrote "$150k-$170k" which parsed to `150` instead of `150000`. Fix: we normalized the salary parsing in the tool handler to detect common patterns: "k" suffix multiplies by 1000, "lakh" multiplies by 100000, and bare numbers above 1000 are treated as-is. We also added a system prompt instruction telling the agent to always confirm the salary in "annual USD" before calling the tool.

```bash
struere run-tool match_candidate_to_roles --args '{"yearsExperience": 6, "primaryStack": "React/Node/PostgreSQL", "salaryExpectation": "150k-170k", "visaStatus": "citizen"}'
```

The output showed `salaryMatch: false` for every role because the parsed value was 150, not 150000.

**Calendar offered slots during a recruiter's existing interviews.** Jordan had back-to-back candidate calls from 1 PM to 3 PM on Thursday, but `calendar.freeBusy` returned those slots as free because the calls were on Zoom — not on Jordan's Google Calendar. The agent offered 1:30 PM to a new candidate. Fix: TalentLoop's recruiters now block interview time on their Google Calendar (a 30-second habit). We also added a buffer rule to the system prompt: "Always leave a 15-minute gap between scheduled phone screens to account for calls that run long."

```bash
struere dev --verbose
```

The verbose output showed `calendar.freeBusy` returning Jordan as available at 1:30 PM. The tool was correct — the calendar genuinely showed free time. The problem was upstream: the recruiter's schedule was not reflected in the calendar.

# Setup: From Zero to Running

Install the CLI and authenticate:

```bash
bun install -g struere
struere login
struere init
```

Scaffold the resources:

```bash
struere add data-type candidate-screen
struere add agent screening-agent
```

Edit `entity-types/candidate-screen.ts` with the schema above. Customize the `visaStatus` enum for your market — EU firms would replace H1B options with Blue Card, Tier 2, etc. Update the `assignedRecruiter` enum with your team's names.

Edit `agents/screening-agent.ts` with your recruiters, their specialties, and your screening criteria. The scoring thresholds (6+ for instant scheduling, 4-5 for review, 3 or below for rejection) should match your firm's selectivity.

Write the custom tool in `tools/index.ts`. Set your `AIRTABLE_BASE_ID` in the dashboard under Settings > Environment Variables. The Airtable table needs columns: Role Title, Required Stack, Min Years, Max Salary, Sponsors Visa (checkbox), Remote Policy, Assigned Recruiter, Client Company, and Status.

Add your Airtable personal access token under Integrations > Airtable in the dashboard.

Connect Google Calendar under Integrations > Google Calendar. Each recruiter needs their own calendar so `calendar.freeBusy` can check individual availability.

Connect WhatsApp under Integrations > WhatsApp. This routes inbound candidate messages to the screening agent.

Sync and start watching for changes:

```bash
struere dev
```

Test the screening flow by sending a WhatsApp message to the connected number:

```
Hi, I'm looking for senior backend engineering roles
```

Watch the conversation unfold in real time:

```bash
struere logs list --last 10
```

Verify the candidate record was created:

```bash
struere data list candidate-screen
```

Check that the phone screen appeared on the recruiter's Google Calendar. The event title should include the candidate name, role, and recruiter.

# What Changed

| Metric | Before | After |
|--------|--------|-------|
| Screening channel | Phone calls only | WhatsApp + phone |
| Time per screen | 12-15 minutes | 3 minutes (automated) |
| Recruiter hours on screening/week | 25+ hours | 4 hours (qualified candidates only) |
| Unqualified candidates reaching recruiters | ~30% of all calls | Under 5% |
| Time to first recruiter contact (strong candidates) | 2-4 days | Same day |
| Screening capacity | ~50 candidates/week | 150+ candidates/week |
| Lost placements from slow response | ~1 per quarter | 0 |

Mia's recruiters still take phone screens. That part is human and should stay human — evaluating culture fit, selling the opportunity, reading between the lines. But the 70% of initial calls that ended with "this person is not a fit" no longer happen. The agent collects the same six data points in 3 minutes over WhatsApp that a recruiter used to spend 12 minutes collecting on a call.

The candidate who would have waited 4 days for Jordan to clear her screening backlog now gets scored in real time and lands on Jordan's calendar the same afternoon. TalentLoop gets the placement. The candidate gets a faster process. The recruiters get to spend their time on the part of recruiting that actually requires a human: closing.
