Predictive Lead Scoring with a Trained Model in TypeScript
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 CompanyandWebsite Technology Stack, discovered once viaclay 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-learnor 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."