# We Lost a $340K Case Because the Intake Call Went to Voicemail. So We Built an AI Agent.
> A personal injury law firm missed high-value cases because prospects couldn't reach the intake paralegal. They built a Struere agent on WhatsApp that qualifies leads, collects case details, checks conflicts, and books consultations in under 3 minutes.

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


# 25 Calls a Day, One Paralegal, and a $340K Case That Walked

Javier is the managing partner at Reyes & Associates, a four-attorney personal injury firm in Phoenix. His intake paralegal, Dana, is the first voice every potential client hears. She answers the phone, screens the case, collects incident details, checks for conflicts of interest against 400+ active and former clients, and schedules consultation calls with the appropriate attorney.

On a typical day, 25-30 calls come in. About 18 of those are prospective clients describing car accidents, slip-and-falls, workplace injuries, and medical malpractice. Each intake call takes 10-15 minutes. Dana asks about the incident date, injury severity, whether they have seen a doctor, whether they have spoken to the other party's insurance company, and whether they have retained another attorney. She types everything into a spreadsheet, cross-references the opposing party's name against their existing client list for conflicts, and then opens Google Calendar to find 30 minutes on the right attorney's schedule.

That is 3-4.5 hours of her day on intake calls alone. Between calls, she processes demand letters, files medical records requests, and coordinates with adjusters. When she is on the phone with one prospect, three others go to voicemail.

Last month, a woman named Patricia left a voicemail on a Thursday afternoon about a commercial truck collision. Dana was on another call. Patricia called back Friday morning, got voicemail again. By Friday afternoon, Patricia had signed with a competitor firm. The case settled for $340,000. Reyes & Associates' contingency fee would have been $113,000.

That was one call. Dana estimates 5-8 qualified leads per week go unanswered or abandoned because of hold times. At an average case value of $45,000 and a 33% contingency fee, that is $75,000-$120,000 in lost annual revenue from missed intake calls alone.

The firm did not need another paralegal at $55,000/year. It needed a channel that prospects could use at 11 PM on a Saturday after a car accident, with conflict checks that run in seconds instead of minutes.

# The Build: WhatsApp Intake Agent with Conflict Screening

The automation has three pieces:

1. **`entity-types/intake-lead.ts`** stores every potential client with case details, injury information, conflict check status, and consultation scheduling
2. **`tools/index.ts`** has a custom conflict-checking tool that queries existing clients by opposing party name and incident details
3. **`agents/intake-agent.ts`** handles the full intake conversation over WhatsApp, from initial screening through consultation booking

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

## The Data Layer: What Gets Stored

Every intake creates a record with fields specific to personal injury case qualification. The `caseType` enum maps to the firm's practice areas, and `conflictCheckStatus` tracks whether the opposing party has been screened.

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

export default defineData({
  name: "Intake Lead",
  slug: "intake-lead",
  schema: {
    type: "object",
    properties: {
      prospectName: {
        type: "string",
        description: "Full name of the prospective client",
      },
      prospectPhone: {
        type: "string",
        description: "WhatsApp phone number in E.164 format",
      },
      prospectEmail: {
        type: "string",
        format: "email",
        description: "Email address for follow-up correspondence",
      },
      caseType: {
        type: "string",
        enum: [
          "auto-accident",
          "truck-accident",
          "slip-and-fall",
          "workplace-injury",
          "medical-malpractice",
          "product-liability",
          "other",
        ],
        description: "Category of personal injury claim",
      },
      incidentDate: {
        type: "string",
        description: "Date of the incident in ISO format (YYYY-MM-DD)",
      },
      injuryDescription: {
        type: "string",
        description: "Brief description of injuries sustained",
      },
      opposingParty: {
        type: "string",
        description: "Name of the at-fault party, business, or insurer",
      },
      insuranceProvider: {
        type: "string",
        description: "Opposing party's insurance company, if known",
      },
      statuteOfLimitationsDeadline: {
        type: "string",
        description: "Calculated SOL deadline in ISO format (YYYY-MM-DD)",
      },
      conflictCheckStatus: {
        type: "string",
        enum: ["pending", "clear", "flagged", "confirmed-conflict"],
        description: "Result of conflict-of-interest screening",
      },
      consultationScheduled: {
        type: "boolean",
        description: "Whether a consultation call has been booked",
      },
      assignedAttorney: {
        type: "string",
        enum: [
          "Javier Reyes",
          "Linda Chen",
          "Marcus Webb",
          "Fatima Al-Rashid",
        ],
        description: "Attorney assigned for the consultation",
      },
      status: {
        type: "string",
        enum: [
          "screening",
          "qualified",
          "conflict-flagged",
          "consultation-booked",
          "retained",
          "declined",
          "disqualified",
        ],
        description: "Current intake pipeline status",
      },
      notes: {
        type: "string",
        description: "Additional context from the intake conversation",
      },
    },
    required: [
      "prospectName",
      "prospectPhone",
      "caseType",
      "incidentDate",
      "injuryDescription",
      "status",
    ],
  },
  searchFields: ["prospectName", "opposingParty", "insuranceProvider"],
  displayConfig: {
    titleField: "prospectName",
    subtitleField: "caseType",
    descriptionField: "injuryDescription",
  },
})
```

The `opposingParty` field is critical for conflict checks. The `searchFields` include it so the agent can query existing intake records by opposing party name. The `statuteOfLimitationsDeadline` stores the calculated deadline based on Arizona's 2-year personal injury statute, giving attorneys immediate visibility into urgency.

## The Custom Tool: Conflict-of-Interest Screening

The core custom tool queries existing clients and past intakes by opposing party name. In a personal injury firm, representing both sides of an accident is a disqualifying conflict. The tool searches across all intake records and returns any matches.

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

export default defineTools([
  {
    name: "check_conflicts",
    description:
      "Check for conflicts of interest by searching existing clients and intake records for the opposing party name",
    parameters: {
      type: "object",
      properties: {
        opposingPartyName: {
          type: "string",
          description: "Name of the opposing party to check against",
        },
        incidentDate: {
          type: "string",
          description: "Date of the incident (YYYY-MM-DD) for cross-referencing",
        },
      },
      required: ["opposingPartyName"],
    },
    handler: async (args, context, struere, fetch) => {
      const existingLeads = await struere.entity.query({
        type: "intake-lead",
        limit: 100,
      })

      const opposingName = (args.opposingPartyName as string).toLowerCase()

      const conflicts = existingLeads.filter((lead: any) => {
        const nameMatch =
          lead.data.prospectName?.toLowerCase().includes(opposingName) ||
          lead.data.opposingParty?.toLowerCase().includes(opposingName)
        return nameMatch && lead.data.status !== "disqualified"
      })

      return {
        hasConflict: conflicts.length > 0,
        matchCount: conflicts.length,
        matches: conflicts.map((lead: any) => ({
          prospectName: lead.data.prospectName,
          caseType: lead.data.caseType,
          incidentDate: lead.data.incidentDate,
          status: lead.data.status,
        })),
      }
    },
  },
  {
    name: "get_current_time",
    description: "Get the current date and time in a specific timezone",
    parameters: {
      type: "object",
      properties: {
        timezone: {
          type: "string",
          description: "Timezone (e.g., \"America/Phoenix\", \"UTC\")",
        },
      },
    },
    handler: async (args, context, struere, fetch) => {
      const timezone = (args.timezone as string) || "America/Phoenix"
      const now = new Date()
      return {
        timestamp: now.toISOString(),
        formatted: now.toLocaleString("en-US", { timeZone: timezone }),
        timezone,
      }
    },
  },
])
```

The conflict check is intentionally broad. It flags any name match across prospect names and opposing parties. False positives are expected and handled by the agent, which escalates flagged conflicts to the attorney rather than making a final determination. Only a licensed attorney can clear a conflict.

## The Agent: Intake Qualification Over WhatsApp

The agent handles the full intake conversation. The system prompt encodes the firm's qualification criteria, conflict screening workflow, statute of limitations awareness, and strict boundaries around legal advice.

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

export default defineAgent({
  name: "Reyes Intake Agent",
  slug: "intake-agent",
  version: "1.0.0",
  model: {
    model: "anthropic/claude-sonnet-4-6",
    temperature: 0.3,
    maxTokens: 4096,
  },
  tools: [
    "entity.create",
    "entity.query",
    "calendar.freeBusy",
    "calendar.create",
    "email.send",
    "check_conflicts",
  ],
  systemPrompt: `You are the intake assistant for Reyes & Associates, a personal injury law firm in Phoenix, Arizona.
Current time: {{currentTime}}

## P0 — Legal Compliance
- You are NOT an attorney. Never provide legal advice, case evaluations, or opinions on liability.
- Never tell a prospect they "have a case" or "will win." Say "based on what you've described, an attorney would like to discuss this with you."
- Never discuss fee structures, contingency percentages, or retainer amounts.
- Never reveal information about other clients, cases, or opposing parties.
- Never instruct the prospect to contact the opposing party or their insurance company.
- If asked for legal advice, respond: "I can't provide legal guidance, but I can schedule you with one of our attorneys who can answer that."

## P1 — Statute of Limitations Awareness
- Arizona personal injury statute of limitations: 2 years from the incident date.
- If the incident is within 60 days of the SOL deadline, mark as URGENT in notes and prioritize scheduling.
- If the incident appears to be past the 2-year window, still collect details but note "potential SOL issue — attorney review required."
- Never tell the prospect their claim is time-barred. Only an attorney can make that determination.

## Attorneys
| Attorney | Specialization |
|----------|---------------|
| Javier Reyes | Truck accidents, complex multi-party |
| Linda Chen | Medical malpractice, product liability |
| Marcus Webb | Auto accidents, slip-and-fall |
| Fatima Al-Rashid | Workplace injury, construction accidents |

## P2 — Intake Flow
1. Greet warmly. Ask what happened (open-ended).
2. Identify the case type from their description.
3. Ask for the incident date.
4. Ask for a brief description of injuries.
5. Ask for the opposing party's name or business (for conflict check).
6. Run check_conflicts with the opposing party name.
7. If conflict flagged: tell the prospect "we need to run an internal review before scheduling — we'll follow up within 24 hours." Create entity with status "conflict-flagged." Do NOT schedule.
8. If clear: ask for full name, email, and phone if not already provided.
9. Calculate the statute of limitations deadline (incident date + 2 years).
10. Use calendar.freeBusy to find available 30-minute slots with the appropriate attorney.
11. Offer 2-3 available slots within the next 5 business days.
12. On confirmation: entity.create with all details, then calendar.create for the consultation.
13. Send a confirmation email via email.send with date, time, attorney name, and what to bring (ID, medical records, police report, insurance correspondence).

## P3 — Tone
- Professional but empathetic. These people are hurt and stressed.
- Short messages. This is WhatsApp, not a legal brief.
- One question at a time.
- Use the prospect's first name once provided.
- Avoid legal jargon. Say "the person who caused the accident" not "the tortfeasor."

## Existing Intake Records
{{entity.query({"type": "intake-lead", "filters": {"status": {"_op_in": ["screening", "qualified", "consultation-booked", "retained"]}}, "limit": 50})}}

Never invent availability. Never confirm without all required fields.
Never re-ask for information already provided in this conversation.
Never schedule a consultation if a conflict is flagged — escalate to attorney.`,
})
```

Temperature 0.3. Legal intake is a structured, high-stakes task. The agent follows the same qualification steps every time. The P0 legal compliance section sits at the top of the prompt because a single instance of the agent giving legal advice could create liability for the firm.

Six tools total. This edges above the five-tool best practice, but `email.send` is only called once at the end of the flow and `check_conflicts` is a single-purpose lookup. The agent's decision tree is still linear: qualify, check conflicts, schedule, confirm.

# Debugging: Three Things That Broke

**The agent calculated statute of limitations deadlines wrong.** A prospect described a car accident from "about two years ago" and the agent estimated the incident date as exactly 730 days back, placing it one day before the SOL deadline. The agent marked it as urgent. In reality, the prospect later clarified the accident was 26 months ago. Fix: we added an instruction to the system prompt: "If the prospect gives an approximate date ('about two years ago,' 'last summer'), ask for the specific date. Never estimate incident dates. The statute of limitations calculation must use an exact date provided by the prospect."

We caught this during testing:

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

The conversation log showed the agent converting "about two years ago" into a specific ISO date and running the SOL calculation on a guess. The `incidentDate` field in the created entity was fabricated.

**The conflict check flagged "State Farm" as a conflict on every intake.** State Farm is the opposing insurer on roughly 40% of Arizona auto accident cases. The `check_conflicts` tool was matching on the `insuranceProvider` field because "State Farm" appeared in dozens of existing records. Every new auto accident prospect with State Farm involvement got flagged, blocking consultation scheduling. Fix: we narrowed the conflict check to match only on `prospectName` and `opposingParty` (the at-fault individual or business), not on insurance provider names. Insurance companies are not parties to the conflict — the individuals are.

```bash
struere run-tool check_conflicts --args '{"opposingPartyName": "State Farm"}'
```

The output returned 47 matches. Every existing auto accident intake with State Farm as the insurer was flagged as a conflict.

**The agent discussed contingency fees when asked "how much does this cost."** Despite the P0 rule against discussing fees, a prospect asked "so what's your fee?" and the agent responded with "personal injury firms typically work on contingency, meaning you don't pay unless we win." This is technically accurate but constitutes the agent making representations about the firm's fee structure. Fix: we strengthened the P0 instruction to: "If asked about fees, costs, or payment, respond exactly: 'Our attorneys will discuss fees and payment during your consultation. There is no charge for the initial consultation.'" A scripted response eliminates the risk of the agent improvising fee-related language.

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

The verbose output showed the agent reasoning: "The user is asking about cost. Personal injury firms use contingency fees. I should explain this." The agent treated it as a factual question rather than a compliance boundary.

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

Edit `entity-types/intake-lead.ts` with the schema shown above. Customize the `caseType` enum for your firm's practice areas and the `assignedAttorney` enum with your attorneys' names.

Edit `agents/intake-agent.ts` with your firm's details: attorney names, specializations, office hours, and state-specific statute of limitations periods. Arizona uses 2 years for personal injury — adjust for your jurisdiction.

Write the two custom tools in `tools/index.ts`. The `check_conflicts` tool works immediately against your existing intake records. The `get_current_time` tool ensures the agent knows the current date for SOL calculations.

Connect Google Calendar in the dashboard under Integrations > Google Calendar. Each attorney needs a separate calendar so `calendar.freeBusy` can check individual availability for consultation slots.

Connect WhatsApp in the dashboard under Integrations > WhatsApp. This routes inbound messages from prospective clients to the intake agent.

Configure Resend under Integrations > Email with your API key and sender address. Use a professional sender like `intake@reyeslaw.com` for confirmation emails.

Sync and start watching for changes:

```bash
struere dev
```

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

```
Hi, I was in a car accident last week and I think I need a lawyer
```

Watch the conversation in real time:

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

Verify the intake record was created:

```bash
struere data list intake-lead
```

Check that the consultation appears on the attorney's Google Calendar. The event should include the prospect name, case type, and incident date.

# What Changed

| Metric | Before | After |
|--------|--------|-------|
| Intake channel | Phone only | WhatsApp + phone |
| Average intake time | 10-15 minutes | 3 minutes |
| Missed qualified leads/week | 5-8 | 0 (WhatsApp is async) |
| Hours spent on intake calls/day | 3-4.5 hours | 45 min (complex cases only) |
| Conflict check time | 5-10 min (manual spreadsheet) | 2 seconds |
| After-hours intake capability | None | 24/7 via WhatsApp |
| SOL deadline tracking | Manual calendar reminders | Automatic on every intake |

Dana still answers the phone. Some prospects, especially those calling from an accident scene or a hospital, need a human voice. But the 18 routine intake calls that consumed her mornings now happen on WhatsApp without her involvement. She gets an email notification when each consultation is booked and can prepare the case file before the attorney's call.

The conflict check that used to take 5-10 minutes of scrolling through a spreadsheet now runs in 2 seconds. The false positives from the State Farm issue are gone. When a real conflict is flagged, the attorney reviews it within 24 hours instead of discovering it during the consultation.

Patricia's truck accident call would not have gone to voicemail. She would have messaged on WhatsApp at 3 PM on Thursday, answered six questions over the next three minutes, passed the conflict check, and had a consultation with Javier scheduled for Friday morning. The $340,000 case would not have walked across the street.
