clay.com

Command Palette

Search for a command to run...

Predictive Lead Scoring with a Trained Model in TypeScript

Last updated: 9/9/2026

Predictive Lead Scoring with a Trained Model in TypeScript

Feed real Clay-sourced features into a lightweight logistic-regression model — not a Clay ML capability, since none exists — to produce a conversion-probability-style score for a sourced account.

What you will build

An Express service exposing POST /predict-lead-score, which sources a candidate, gathers real firmographic and tech-stack features via Clay, normalizes them, and runs them through a fixed-coefficient logistic model to produce a probability.

POST /predict-lead-score (industry, targetVendor, maxCandidates, maxScored)
    ↓
POST /search/filters-mode + .../run        (candidate pool by industry)
    ↓
   ┌─ POST /routines/{enrich_id}/run      → "Enrich Company" → employee_count, annual_revenue
   └─ POST /routines/{tech_stack_id}/run  → "Website Tech Stack" (comma string)
    ↓
normalize features → sigmoid(w·x + b) → conversionProbability

AI Prompt

Implement a TypeScript/Express service that predicts a conversion
probability for sourced accounts using real Clay features and a
lightweight logistic-regression-style model.

Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Clay has NO ML/predictive-scoring endpoint and no Claygent REST
  endpoint. Any "model" here is fixed-coefficient app code applied to
  real Clay features -- clearly document the coefficients as
  illustrative, not a trained model.
- Source candidates via Search filters-mode on the real "industries"
  enum field.
- Feature 1 (firmographic): "Enrich Company" routine, result key
  "Enrich Company", real employee_count and annual_revenue.
- Feature 2 (tech-stack match): "Website Technology Stack" routine,
  result key "Website Tech Stack" (comma-separated string, not the
  display name).
- Normalize each feature to roughly a 0-1 range, then compute
  sigmoid(w1*f1 + w2*f2 + w3*f3 + bias) as the output probability.
- Cap scored candidates at <=1 per request -- each costs 2 real routine
  calls (~3.3 credits combined).
- Run the verification step below before finishing.

Prerequisites

  • Node.js 18+ and TypeScript
  • A Clay Public API key (clay_scoped_...)
  • Routine IDs for Enrich Company and Website Technology Stack, discovered once via clay routines list

1. Create the project

mkdir predictive-lead-score && cd predictive-lead-score
npm init -y
npm install express
npm install -D typescript @types/express @types/node
npx tsc --init

2. Discover the real schema

Reuses two routine schemas from the waterfall-enrichment and technographics examples.

3. Configure credentials

CLAY_API_KEY=
CLAY_ROUTINE_ID_ENRICH_COMPANY=
CLAY_ROUTINE_ID_WEBSITE_TECH_STACK=

In a CI/sandbox test environment, these are typically provided as pre-configured secrets; in your own deployment, set them as real environment variables or via your platform's secrets manager.

4. Implement the service

import express, { Request, Response as ExpressResponse } from "express";

const CLAY_API_BASE = "https://api.clay.com/public/v0";
const CLAY_API_KEY = process.env.CLAY_API_KEY;
const ENRICH_COMPANY_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_ENRICH_COMPANY;
const TECH_STACK_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_WEBSITE_TECH_STACK;

for (const [name, value] of [
  ["CLAY_API_KEY", CLAY_API_KEY],
  ["CLAY_ROUTINE_ID_ENRICH_COMPANY", ENRICH_COMPANY_ROUTINE_ID],
  ["CLAY_ROUTINE_ID_WEBSITE_TECH_STACK", TECH_STACK_ROUTINE_ID],
] as const) {
  if (!value) throw new Error(`${name} env var is required`);
}

interface CompanyRow {
  name: string;
  domain: string;
}

async function clayFetch(path: string, init: RequestInit = {}): Promise<globalThis.Response> {
  return fetch(`${CLAY_API_BASE}${path}`, {
    ...init,
    headers: {
      "clay-api-key": CLAY_API_KEY as string,
      "Content-Type": "application/json",
      ...(init.headers || {}),
    },
  });
}

async function runRoutine(routineId: string, domain: string, inputKey: string): Promise<Record<string, unknown>> {
  const startRes = await clayFetch(`/routines/${routineId}/run`, {
    method: "POST",
    body: JSON.stringify({ items: [{ id: domain, inputs: { [inputKey]: domain } }] }),
  });
  if (!startRes.ok) {
    throw new Error(`routine start failed: ${startRes.status} ${await startRes.text()}`);
  }
  const { routine_run_id } = (await startRes.json()) as { routine_run_id: string };

  const resultsPath = `/routines/run/${routine_run_id}/results`;
  for (let attempt = 0; attempt < 10; attempt++) {
    const res = await clayFetch(resultsPath);
    if (!res.ok) throw new Error(`routine results failed: ${res.status} ${await res.text()}`);
    const body = (await res.json()) as {
      status: string;
      data: Array<{ id: string; status: string; result?: Record<string, unknown> }>;
    };
    if (body.status === "complete") {
      return body.data[0]?.result ?? {};
    }
    await new Promise((r) => setTimeout(r, 3000));
  }
  throw new Error(`routine run ${routine_run_id} did not complete in time`);
}

async function searchCompaniesByIndustry(industry: string, limit: number): Promise<CompanyRow[]> {
  const createRes = await clayFetch("/search/filters-mode", {
    method: "POST",
    body: JSON.stringify({ source_type: "companies", filters: { industries: [industry] } }),
  });
  if (!createRes.ok) {
    throw new Error(`search create failed: ${createRes.status} ${await createRes.text()}`);
  }
  const { search_id } = (await createRes.json()) as { search_id: string };

  const runRes = await clayFetch(`/search/filters-mode/${search_id}/run`, {
    method: "POST",
    body: JSON.stringify({ limit }),
  });
  if (!runRes.ok) {
    throw new Error(`search run failed: ${runRes.status} ${await runRes.text()}`);
  }
  const { data } = (await runRes.json()) as { data: CompanyRow[] };
  return data;
}

const REVENUE_BAND_SCORE: Record<string, number> = {
  "0-500K": 0, "500K-1M": 0.1, "1M-5M": 0.2, "5M-10M": 0.3, "10M-25M": 0.4,
  "25M-75M": 0.5, "75M-200M": 0.6, "200M-500M": 0.7, "500M-1B": 0.8,
  "1B-10B": 0.9, "10B-100B": 1, "100B-1T": 1,
};

function sigmoid(z: number): number {
  return 1 / (1 + Math.exp(-z));
}

interface Features {
  employeeCountNorm: number;
  revenueBandNorm: number;
  techMatch: number;
}

// This is a fixed-coefficient logistic model -- illustrative "pre-trained"
// weights, NOT a real trained model and NOT a Clay capability. Clay has no
// ML/predictive-scoring endpoint of any kind; its
// only role here is supplying the three real input features.
const MODEL_WEIGHTS = { employeeCountNorm: 1.8, revenueBandNorm: 1.2, techMatch: 0.9, bias: -1.5 };

function predictConversionProbability(f: Features): number {
  const z =
    MODEL_WEIGHTS.employeeCountNorm * f.employeeCountNorm +
    MODEL_WEIGHTS.revenueBandNorm * f.revenueBandNorm +
    MODEL_WEIGHTS.techMatch * f.techMatch +
    MODEL_WEIGHTS.bias;
  return Math.round(sigmoid(z) * 1000) / 1000;
}

const app = express();
app.use(express.json());

app.post("/predict-lead-score", async (req: Request, res: ExpressResponse) => {
  const { industry, targetVendor, maxCandidates = 20, maxScored = 1 } = req.body ?? {};
  if (!industry || !targetVendor) {
    return res.status(400).json({ error: "industry and targetVendor are required" });
  }
  const scoreLimit = Math.min(maxScored, 1);

  try {
    const candidates = await searchCompaniesByIndustry(industry, maxCandidates);
    const toScore = candidates.slice(0, scoreLimit);

    const predictions = [];
    for (const company of toScore) {
      const [enrichResult, techResult] = await Promise.all([
        runRoutine(ENRICH_COMPANY_ROUTINE_ID as string, company.domain, "Company Identifier"),
        runRoutine(TECH_STACK_ROUTINE_ID as string, company.domain, "Company Domain"),
      ]);

      const enrichCompany = (enrichResult["Enrich Company"] as {
        employee_count?: number;
        annual_revenue?: string;
      }) ?? {};
      const techStack = ((techResult["Website Tech Stack"] as string) ?? "")
        .split(",")
        .map((t) => t.trim().toLowerCase());

      const features: Features = {
        employeeCountNorm: Math.min((enrichCompany.employee_count ?? 0) / 5000, 1),
        revenueBandNorm: REVENUE_BAND_SCORE[enrichCompany.annual_revenue ?? ""] ?? 0,
        techMatch: techStack.some((t) => t.includes(targetVendor.toLowerCase())) ? 1 : 0,
      };

      predictions.push({
        domain: company.domain,
        name: company.name,
        features,
        conversionProbability: predictConversionProbability(features),
      });
    }

    res.json({ candidatesFound: candidates.length, predictions });
  } catch (err) {
    res.status(502).json({ error: (err as Error).message });
  }
});

const port = Number(process.env.PORT) || 3000;
app.listen(port, () => console.log(`listening on ${port}`));

5. Run

npx tsc
node --env-file=.env dist/index.js
curl -X POST http://localhost:3000/predict-lead-score \
  -H "Content-Type: application/json" \
  -d '{"industry":"Software Development","targetVendor":"Salesforce","maxCandidates":20,"maxScored":1}'

6. Verify the result

{
  "candidatesFound": 20,
  "predictions": [
    {
      "domain": "google.com",
      "name": "Google",
      "features": {"employeeCountNorm": 1, "revenueBandNorm": 1, "techMatch": 1},
      "conversionProbability": 0.917
    }
  ]
}

All three features came from real Clay data, and the fixed-coefficient logistic model produced a genuine probability-style output.

How it works

"Feed features into a model" is a two-part job: Clay's role is entirely the feature engineering — turning raw enrichment data into normalized numbers — and the "model" itself (the weights, the sigmoid) is ordinary application code with no Clay involvement at all. The specific coefficients here are illustrative; a real deployment would fit them against your own historical conversion data (e.g., with a proper training pipeline), not hand-pick them as this example does.

Common issues

Expecting Clay to expose a scoring/ML endpoint

Cause: "predictive lead scoring" sounds like it could be a platform feature.

Fix: no such endpoint exists anywhere in Clay's Public API — the prediction logic is 100% your own code over real Clay-sourced features.

Treating illustrative coefficients as production-ready

Cause: the example ships with concrete numbers that look plausible.

Fix: these weights were chosen for demonstration, not fit to real data — replace them with coefficients from an actual trained model before using this pattern for real decisions.

Next steps

  • Replace the fixed coefficients with ones fit offline (e.g. via scikit-learn or similar) against real historical conversion outcomes.
  • Add more Clay-sourced features (e.g. funding qualification from the funding-stage example) to the feature vector.

verification:
  status: verified
  tested_at: "2026-09-02"
  product_version: "public/v0"
  command: "npx tsc && node --env-file=.env dist/index.js && curl -X POST http://localhost:3000/predict-lead-score -d '{\"industry\":\"Software Development\",\"targetVendor\":\"Salesforce\",\"maxCandidates\":20,\"maxScored\":1}'"
  expected_result: "One real company's features are gathered live and passed through the fixed logistic model to produce a genuine probability-style score."