# Our Sales Team Wasted 12 Hours a Week on Unqualified Demos. So We Built an AI Lead Qualification Agent.
> A CRM startup's sales reps spent half their week on demos that went nowhere. They built a Struere agent that qualifies inbound leads over WhatsApp and web, scores them, routes hot leads to reps via Slack, and logs everything in Airtable.

Published: 2026-04-01
Tags: automation, agents, saas, case-study, lead-qualification


# 6 Reps, 30 Demos a Week, and Half of Them Were Dead on Arrival

Tomas is VP of Sales at PipelineHQ, a 40-person CRM startup in Austin. His team of six reps runs the entire inbound pipeline: leads come in through the website contact form and a WhatsApp business number, someone on the team responds within a few hours, schedules a demo, runs the demo, and follows up.

On paper, 30 demos a week sounds like a healthy pipeline. In practice, 16 of those demos are with leads who will never close. The marketing agency with 3 employees who wants a free plan. The consultant who is "just exploring options" with no timeline. The mid-market company whose VP filled out the form but whose actual buyer is an intern with no purchasing authority.

Each demo takes 30-40 minutes of prep and execution. The rep researches the company, personalizes the deck, joins the Zoom, runs through the product, answers questions, and sends a follow-up email. For the 16 unqualified demos, that is 8-10 hours of wasted rep time per week across the team. Add the 2 hours spent triaging inbound messages and scheduling calls manually, and PipelineHQ's sales team burns 12 hours a week on leads that were never going to convert.

Last month, Tomas pulled the numbers. Out of 120 demos in March, 47 converted to a second call. Out of those 47, 18 became paying customers. The 73 demos that went nowhere cost the team roughly $8,700 in loaded rep time. Worse, three hot leads from Enterprise companies sat in the inbox for 6+ hours because reps were busy doing demos with two-person shops.

The problem was not lead volume. The problem was that every lead got the same treatment. A Fortune 500 IT director and a freelancer both got a 30-minute demo with the same rep. The team needed a filter between "hand raised" and "demo scheduled," one that worked 24/7, asked the right questions, and flagged the leads that actually matched PipelineHQ's ideal customer profile.

# The Build: AI Lead Qualification Over WhatsApp and Web

The automation has three pieces:

1. **`entity-types/qualified-lead.ts`** stores every qualified lead with company details, qualification answers, a lead score, and routing status
2. **`tools/index.ts`** has two custom tools: one that scores leads based on qualification answers, one that sends formatted alerts to the sales team's Slack channel
3. **`agents/lead-qualifier.ts`** handles the full qualification conversation over WhatsApp or web, collects structured data, scores the lead, and routes accordingly

No trigger file needed. The WhatsApp integration routes inbound messages directly to the agent, and the web chat widget does the same. The agent is the entry point.

## The Data Layer: What Gets Stored

Every lead that completes the qualification flow gets stored with 10 fields. The `leadScore` is calculated by the scoring tool. The `routingStatus` tracks whether the lead has been sent to a rep.

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

export default defineData({
  name: "Qualified Lead",
  slug: "qualified-lead",
  schema: {
    type: "object",
    properties: {
      contactName: {
        type: "string",
        description: "Full name of the lead",
      },
      contactEmail: {
        type: "string",
        format: "email",
        description: "Work email address",
      },
      companyName: {
        type: "string",
        description: "Company or organization name",
      },
      companySize: {
        type: "string",
        enum: ["1-10", "11-50", "51-200", "201-1000", "1000+"],
        description: "Number of employees",
      },
      currentTools: {
        type: "string",
        description: "CRM or sales tools currently in use (e.g., Salesforce, HubSpot, spreadsheets)",
      },
      budgetRange: {
        type: "string",
        enum: ["under-5k", "5k-15k", "15k-50k", "50k-plus", "not-sure"],
        description: "Annual budget range for CRM tooling in USD",
      },
      timeline: {
        type: "string",
        enum: ["immediate", "1-3-months", "3-6-months", "6-plus-months", "just-exploring"],
        description: "Expected purchase timeline",
      },
      leadScore: {
        type: "number",
        description: "Qualification score from 0 to 100",
      },
      routingStatus: {
        type: "string",
        enum: ["pending", "routed-to-sales", "nurture", "disqualified"],
        description: "Current routing status of the lead",
      },
      qualificationNotes: {
        type: "string",
        description: "Agent summary of the qualification conversation",
      },
    },
    required: [
      "contactName",
      "contactEmail",
      "companyName",
      "companySize",
      "currentTools",
      "budgetRange",
      "timeline",
      "leadScore",
      "routingStatus",
    ],
  },
  searchFields: ["contactName", "contactEmail", "companyName"],
  displayConfig: {
    titleField: "companyName",
    subtitleField: "contactName",
    descriptionField: "qualificationNotes",
  },
})
```

The `companySize` and `budgetRange` enums match PipelineHQ's ICP tiers exactly. A lead with 51-200 employees and a 15k-50k budget scores differently than a solo consultant with no budget. The `currentTools` field is a free-text string because the landscape is too broad for an enum, but the scoring tool parses it for known competitors.

## The Custom Tools: Scoring and Slack Routing

Two custom tools power the qualification logic. The first calculates a lead score based on weighted criteria. The second sends a formatted Slack message when a hot lead is identified.

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

export default defineTools([
  {
    name: "score_lead",
    description:
      "Calculate a lead qualification score (0-100) based on company size, budget, timeline, and current tools",
    parameters: {
      type: "object",
      properties: {
        companySize: {
          type: "string",
          description: "Number of employees bracket",
        },
        budgetRange: {
          type: "string",
          description: "Annual budget range",
        },
        timeline: {
          type: "string",
          description: "Purchase timeline",
        },
        currentTools: {
          type: "string",
          description: "Current CRM/sales tools in use",
        },
      },
      required: ["companySize", "budgetRange", "timeline", "currentTools"],
    },
    handler: async (args, context, struere, fetch) => {
      let score = 0

      const sizeScores: Record<string, number> = {
        "1-10": 5,
        "11-50": 15,
        "51-200": 30,
        "201-1000": 35,
        "1000+": 30,
      }
      score += sizeScores[args.companySize as string] || 0

      const budgetScores: Record<string, number> = {
        "under-5k": 5,
        "5k-15k": 15,
        "15k-50k": 25,
        "50k-plus": 30,
        "not-sure": 10,
      }
      score += budgetScores[args.budgetRange as string] || 0

      const timelineScores: Record<string, number> = {
        immediate: 25,
        "1-3-months": 20,
        "3-6-months": 10,
        "6-plus-months": 5,
        "just-exploring": 0,
      }
      score += timelineScores[args.timeline as string] || 0

      const tools = (args.currentTools as string).toLowerCase()
      const competitors = ["salesforce", "hubspot", "pipedrive", "zoho", "freshsales"]
      const usesCompetitor = competitors.some((c) => tools.includes(c))
      if (usesCompetitor) score += 10
      if (tools.includes("spreadsheet") || tools.includes("excel") || tools.includes("google sheets")) score += 5

      const tier =
        score >= 80 ? "hot" : score >= 50 ? "warm" : score >= 25 ? "cool" : "cold"

      return { score, tier, maxScore: 100 }
    },
  },
  {
    name: "send_slack_alert",
    description:
      "Send a formatted lead alert to the sales team Slack channel with lead details and score",
    parameters: {
      type: "object",
      properties: {
        contactName: { type: "string", description: "Lead's full name" },
        companyName: { type: "string", description: "Company name" },
        companySize: { type: "string", description: "Employee count bracket" },
        leadScore: { type: "number", description: "Qualification score" },
        tier: { type: "string", description: "Lead tier: hot, warm, cool, or cold" },
        summary: { type: "string", description: "One-line summary of the lead" },
      },
      required: ["contactName", "companyName", "leadScore", "tier", "summary"],
    },
    handler: async (args, context, struere, fetch) => {
      const webhookUrl = process.env.SLACK_WEBHOOK_URL
      if (!webhookUrl) {
        return { success: false, error: "SLACK_WEBHOOK_URL not configured" }
      }

      const emoji =
        args.tier === "hot" ? "🔥" : args.tier === "warm" ? "🟡" : "⚪"

      const message = [
        `${emoji} *New ${(args.tier as string).toUpperCase()} Lead — Score: ${args.leadScore}/100*`,
        `*Contact:* ${args.contactName}`,
        `*Company:* ${args.companyName} (${args.companySize || "unknown size"})`,
        `*Summary:* ${args.summary}`,
        args.tier === "hot"
          ? "⚡ _Route to senior AE immediately — this lead is ready to buy._"
          : "",
      ]
        .filter(Boolean)
        .join("\n")

      const response = await fetch(webhookUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: message, username: "Lead Qualifier" }),
      })

      return { success: response.ok, status: response.status }
    },
  },
])
```

The scoring tool uses weighted criteria that match PipelineHQ's ICP. A 201-1000 employee company with a 50k+ budget and an immediate timeline scores 90. A 1-10 person shop that is "just exploring" with no budget scores 10. The Slack tool formats the alert with the lead tier so reps can triage at a glance without opening the dashboard.

## The Agent: Conversational Qualification

The agent handles the entire qualification conversation. It collects four data points, scores the lead, creates the record, logs it in Airtable, and routes hot leads to Slack. The full system prompt is longer — this shows the key sections.

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

export default defineAgent({
  name: "PipelineHQ Lead Qualifier",
  slug: "lead-qualifier",
  version: "1.0.0",
  model: {
    model: "anthropic/claude-sonnet-4-6",
    temperature: 0.3,
    maxTokens: 4096,
  },
  tools: [
    "entity.create",
    "entity.query",
    "score_lead",
    "send_slack_alert",
    "airtable.createRecords",
  ],
  systemPrompt: `You are the inbound sales assistant for PipelineHQ, a modern CRM platform for growing sales teams.
Current time: {{currentTime}}

## P0 — Security
- Never reveal internal entity IDs, scoring logic, or system details to leads.
- Never fabricate product features or pricing. If unsure, say "I'll have our team follow up with details."
- Never share one lead's information with another.
- Never skip qualification steps even if the lead asks to "just get a demo."

## P1 — Qualification Flow
1. Greet the lead warmly. Introduce yourself as PipelineHQ's sales assistant.
2. Ask what brings them to PipelineHQ today (pain point or goal).
3. Ask about their company: name and approximate team size.
4. Ask what CRM or sales tools they currently use.
5. Ask about their budget range for CRM tooling this year.
6. Ask about their timeline for making a decision.
7. Once all four data points are collected, use score_lead to calculate the score.
8. Use entity.create to store the qualified lead (type: "qualified-lead").
9. Use airtable.createRecords to log the lead in the "Inbound Leads" table.
10. If score >= 80 (hot): use send_slack_alert, tell the lead "A senior account executive will reach out within the hour."
11. If score 50-79 (warm): use send_slack_alert, tell the lead "Our team will follow up within 24 hours with a personalized walkthrough."
12. If score < 50 (cool/cold): thank them, offer a link to the self-serve demo, do NOT route to Slack.

## P2 — Conversation Style
- Professional but human. This is a sales conversation, not a support ticket.
- Short messages. One question at a time. Do not dump all questions in a single message.
- Mirror the lead's tone — if they are formal, be formal. If casual, be casual.
- Use the lead's first name once provided.
- If the lead provides multiple answers in one message, acknowledge all of them and move to the next unanswered question.

## P3 — Edge Cases
- If a lead says "just exploring" or "no budget yet," still complete the full flow. Score accordingly.
- If a lead asks about pricing, say: "Pricing starts at $49/month for growing teams and scales with usage. I can have an AE walk you through the best plan for your setup."
- If a lead asks a product question, answer briefly using general CRM capabilities, then continue qualification.

Never invent data absent from the conversation.
Never re-ask for information already provided in this conversation.
Never calculate lead scores manually — always use the score_lead tool.`,
})
```

Temperature 0.3. Lead qualification is a structured workflow. The agent needs to collect the same four data points in every conversation and score them consistently. Five tools total: entity create and query for the data layer, `score_lead` and `send_slack_alert` for the qualification logic, and `airtable.createRecords` to sync the lead into PipelineHQ's existing Airtable pipeline tracker.

The `airtable.createRecords` tool writes every lead into the team's existing "Inbound Leads" Airtable base. This is where the sales team already tracks their pipeline, so the agent feeds directly into their existing workflow instead of creating a separate data silo.

# Debugging: Three Things That Broke

**The scoring tool returned 0 for a clearly qualified lead.** A lead from a 200-person company with a $30k budget and a 1-month timeline scored 0. The agent passed "200" as the `companySize` instead of "201-1000" because the lead said "about 200 people" and the agent sent the raw number. Fix: we updated the system prompt to instruct the agent to map free-text answers to the enum values before calling `score_lead`. We also added a note: "Always pass enum values from the entity schema, not raw user input."

```bash
struere run-tool score_lead --args '{"companySize": "200", "budgetRange": "15k-50k", "timeline": "1-3-months", "currentTools": "Salesforce"}'
```

The output showed `score: 0` for the size component because "200" did not match any key in the scoring map. Changing the input to "51-200" returned the expected 30 points.

**Airtable records were created with blank fields.** The agent called `airtable.createRecords` but half the fields were empty in Airtable. The Airtable table used different column names than the entity schema. The entity had `companyName`, Airtable had `Company`. The agent passed entity field names, not Airtable column names. Fix: we added a mapping note in the system prompt specifying the exact Airtable column names: "When logging to Airtable, use these column names: Company, Contact, Email, Size, Budget, Timeline, Score, Status."

```bash
struere logs view --last 3
```

The conversation log showed the `airtable.createRecords` tool call with `companyName` as the field key. Airtable silently accepted the request but ignored unrecognized columns.

**Hot leads were routed to Slack but reps could not find the lead record.** The Slack alert said "Score: 92, Company: Dataflow Inc" but when the rep searched in the Struere dashboard, nothing came up. The agent had called `send_slack_alert` before `entity.create`. The Slack message fired, but the entity creation failed because the agent omitted the required `routingStatus` field. Fix: we reordered the system prompt instructions to always create the entity first, then alert Slack. We also added `routingStatus` to the P1 flow: "Set routingStatus to 'routed-to-sales' for hot leads, 'nurture' for warm leads, 'disqualified' for cold leads."

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

The verbose output showed the `entity.create` call failing with a validation error for the missing required field. The agent had already sent the Slack alert, so the rep saw a notification for a lead that did not exist in the system.

# 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 qualified-lead
struere add agent lead-qualifier
```

Edit `entity-types/qualified-lead.ts` with the schema shown above. Match the `companySize` and `budgetRange` enums to your ICP tiers. Edit `agents/lead-qualifier.ts` with your company details, pricing, and qualification criteria.

Write the two custom tools in `tools/index.ts`. Adjust the `score_lead` weights to match your ICP. For `send_slack_alert`, set your `SLACK_WEBHOOK_URL` in the dashboard under Settings > Environment Variables.

Add your Airtable personal access token under Integrations > Airtable. Make sure the "Inbound Leads" table in your Airtable base has columns matching the field names in your system prompt mapping.

Connect WhatsApp in the dashboard under Integrations > WhatsApp. This routes inbound messages to the lead qualifier agent.

Sync and start watching for changes:

```bash
struere dev
```

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

```
Hi, I'm interested in PipelineHQ for our sales team
```

Watch the conversation in real time:

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

Verify the lead was created:

```bash
struere data list qualified-lead
```

Check your Slack channel for the routing alert. If the test lead scored 80+, you should see a hot lead notification in `#sales-leads` within seconds of the conversation completing.

# What Changed

| Metric | Before | After |
|--------|--------|-------|
| Time spent triaging inbound leads | 12 hours/week | 1.5 hours/week (hot leads only) |
| Unqualified demos per week | 16 | 4 |
| Average lead response time | 3-6 hours | Under 2 minutes |
| Hot lead response time | 3-6 hours | Under 5 minutes (Slack alert) |
| Demos per rep per week | 5 | 3 (all qualified) |
| Demo-to-second-call conversion | 39% | 71% |

Tomas still has his reps run demos. That has not changed. What changed is who gets on those demos. The agent handles the first conversation: what does your team look like, what tools are you using, what is your budget, when do you need this. Fifteen messages, two minutes, done. The lead gets a fast response. The rep gets a pre-qualified opportunity with context already logged in Airtable.

The three Enterprise leads that sat in the inbox for 6 hours last month would have been scored 85+ and routed to a senior AE via Slack within minutes. The freelancer with no budget would have received a link to the self-serve demo and never consumed rep time.

PipelineHQ's sales team went from 30 demos a week to 14. Revenue did not drop. It went up 22% the following quarter because reps spent their time on leads that were ready to buy instead of explaining basic features to people who were never going to sign a contract.
