---
title: "Template: Lead Scorer"
description: "LLM-based lead scoring with Supabase storage -- receive a lead, score it with an LLM, store the result"
loadWhen: "Applying the lead-scorer workflow template"
---

**Category:** CRM

**Platform Tools:** `llm` (scoring), `supabase` (store score)

**Credentials Required:**

- `my-database` -- Supabase project URL and service role key
- LLM API keys are resolved server-side from platform configuration (no credential name needed)

---

## What This Workflow Does

Receives a lead with company and contact details, uses an LLM to score the lead on multiple criteria, and stores the scored lead in a Supabase table. The scoring criteria and output fields are customizable. Suitable for inbound lead qualification, prioritization queues, and sales routing.

---

## Input Schema

```typescript
z.object({
  leadId: z.string(),
  company: z.string(),
  role: z.string().optional(),
  email: z.string().optional(),
  notes: z.string().optional(),      // Any additional context about the lead
  scoringCriteria: z.string().optional(), // Custom scoring criteria (overrides default)
})
```

## Output Schema

```typescript
z.object({
  leadId: z.string(),
  score: z.number(),                 // 0-100 overall score
  tier: z.enum(['hot', 'warm', 'cold']),
  reasoning: z.string(),             // LLM explanation of the score
  stored: z.boolean(),
})
```

---

## Workflow Code Pattern

```typescript
import type { WorkflowDefinition } from '@elevasis/sdk'
import { StepType } from '@elevasis/sdk'
import { platform } from '@elevasis/sdk/worker'
import { z } from 'zod'

const inputSchema = z.object({
  leadId: z.string(),
  company: z.string(),
  role: z.string().optional(),
  email: z.string().optional(),
  notes: z.string().optional(),
  scoringCriteria: z.string().optional(),
})
const outputSchema = z.object({
  leadId: z.string(),
  score: z.number(),
  tier: z.enum(['hot', 'warm', 'cold']),
  reasoning: z.string(),
  stored: z.boolean(),
})

const DEFAULT_CRITERIA = `
Score this lead from 0-100 based on:
- Company fit (industry, size, budget signals) -- 40 points
- Role relevance (decision-maker or influencer) -- 30 points
- Engagement potential (any signals from notes) -- 30 points

Return JSON: { "score": number, "tier": "hot"|"warm"|"cold", "reasoning": "..." }
Hot = 70+, Warm = 40-69, Cold = below 40.
`

type Input = z.infer<typeof inputSchema>

export const leadScorer: WorkflowDefinition = {
  config: {
    resourceId: 'lead-scorer',
    name: 'Lead Scorer',
    type: 'workflow',
    description: 'Scores leads using an LLM and stores results in Supabase',
    version: '1.0.0',
    status: 'dev',
  },
  contract: { inputSchema, outputSchema },
  steps: {
    score: {
      id: 'score',
      name: 'Score Lead',
      description: 'Score the lead using an LLM',
      inputSchema,
      outputSchema: z.object({ leadId: z.string(), score: z.number(), tier: z.enum(['hot', 'warm', 'cold']), reasoning: z.string() }),
      handler: async (input) => {
        const { leadId, company, role, notes, scoringCriteria } = input as Input
        const criteria = scoringCriteria ?? DEFAULT_CRITERIA

        const result = await platform.call({
          tool: 'llm',
          method: 'generate',
          params: {
            provider: 'openai',
            model: 'gpt-5.4-mini',
            messages: [{
              role: 'user',
              content: `${criteria}\n\nLead:\nCompany: ${company}\nRole: ${role ?? 'Unknown'}\nNotes: ${notes ?? 'None'}`,
            }],
            responseSchema: {
              type: 'object',
              properties: {
                score: { type: 'number' },
                tier: { type: 'string', enum: ['hot', 'warm', 'cold'] },
                reasoning: { type: 'string' },
              },
            },
          },
        }) as { score: number; tier: 'hot' | 'warm' | 'cold'; reasoning: string }

        return { leadId, score: result.score, tier: result.tier, reasoning: result.reasoning }
      },
      next: { type: StepType.LINEAR, target: 'store' },
    },
    store: {
      id: 'store',
      name: 'Store Score',
      description: 'Save the lead score to Supabase',
      inputSchema: z.object({ leadId: z.string(), score: z.number(), tier: z.enum(['hot', 'warm', 'cold']), reasoning: z.string() }),
      outputSchema,
      handler: async (input, context) => {
        const { leadId, score, tier, reasoning } = input as { leadId: string; score: number; tier: 'hot' | 'warm' | 'cold'; reasoning: string }

        await platform.call({
          tool: 'supabase',
          method: 'update',
          credential: 'my-database',
          params: {
            table: 'leads',
            filter: { id: `eq.${leadId}` },
            data: { score, tier, scoring_reasoning: reasoning, scored_at: new Date().toISOString() },
          },
        })

        context.logger.info('Lead scored and stored', { leadId, score, tier })
        return { leadId, score, tier, reasoning, stored: true }
      },
      next: null,
    },
  },
  entryPoint: 'score',
}
```

---

## Adaptation Notes

- **Credential name:** Replace `'my-database'` with the user's database credential name.
- **Table name:** Replace `'leads'` with the user's actual table name. Check `data/schema.ts` if it exists.
- **Scoring criteria:** Customize `DEFAULT_CRITERIA` based on the user's ICP (ideal customer profile). Ask the user what matters most for their scoring.
- **Table columns:** The template writes `score`, `tier`, `scoring_reasoning`, `scored_at`. Ensure these columns exist or adapt the field names.
- **LLM model:** Default uses `gpt-5.4-mini`. For more nuanced scoring, suggest `gpt-5`.

---

**Last Updated:** 2026-02-26
