Generating Personalized Outbound Copy with TypeScript
Generating Personalized Outbound Copy with TypeScript
Draft a fact-grounded email opening line from a company's real, recent news — not "use Claygent," since Claygent isn't reachable via the Public API and no dedicated copywriting routine exists anywhere in Clay.
What you will build
An Express service exposing POST /draft-opener, which fetches a company's most recent real news event via Clay and drafts a personalized opening line referencing it.
POST /draft-opener (companyName, companyDomain)
↓
POST /routines/{routine_id}/run (Company News)
↓
GET /routines/run/{run_id}/results (poll until complete)
↓
template over the real event summary → openingLine
AI Prompt
Implement a TypeScript/Express service that drafts a personalized email
opening line from a company's real recent news, using Clay's Public API.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Clay has NO AI-copy-generation capability reachable from this API.
Claygent is a separate `clay workflows`/agent-node feature, not part
of the 13-path Public API spec -- do not call it or invent a REST path
for it. There is also no dedicated "copywriting" routine in the
managed routine catalog.
- The real, usable capability here is the "Company News" routine (id via
CLAY_ROUTINE_ID_COMPANY_NEWS), input
{"Company Domain":"<hostname>","Max News Events":"3"}. The
result has TWO top-level keys -- "Company News" (a string
count, not useful content) and "Find Most Recent News" (the real
payload: {domain, events: [{summary, article_sentence, category,
found_at, news_article_attributes:{url,...}, ...}]}). Use
"Find Most Recent News", not the routine's own display name key.
- Generate the opening line as a template over the real event's
"summary" field -- this is illustrative; a real deployment would pass
the same real event data as context to an actual LLM call for freer
phrasing, which is a separate integration from Clay entirely.
- Run the verification step below before finishing.
Prerequisites
- Node.js 18+ and TypeScript
- A Clay Public API key (
clay_scoped_...) - The routine ID for
Company News, discovered once viaclay routines list
1. Create the project
mkdir personalized-opener && cd personalized-opener npm init -y npm install express npm install -D typescript @types/express @types/node npx tsc --init
2. Discover the real schema
clay routines list | grep -B1 -A3 '"Company News"' clay routines get function:t_XXXX
Schema: {"Company Domain":"<hostname>", "Earliest Publish Date YYYY MM DD"?, "Latest Publish Date YYYY MM DD"?, "Max News Events"?} (only domain required), estimatedCreditCost.perRun: 6.7.
3. Configure credentials
CLAY_API_KEY= CLAY_ROUTINE_ID_COMPANY_NEWS=
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 COMPANY_NEWS_ROUTINE_ID = process.env.CLAY_ROUTINE_ID_COMPANY_NEWS;
if (!CLAY_API_KEY) throw new Error("CLAY_API_KEY env var is required");
if (!COMPANY_NEWS_ROUTINE_ID) throw new Error("CLAY_ROUTINE_ID_COMPANY_NEWS env var is required");
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 || {}),
},
});
}
interface NewsEvent {
summary?: string;
article_sentence?: string;
category?: string;
found_at?: string;
}
interface CompanyNewsResult {
"Find Most Recent News"?: { domain?: string; events?: NewsEvent[] };
}
// Clay has NO AI-copy-generation capability reachable from this
// API -- Claygent is a separate workflow-builder feature (not in the 13-path
// Public API spec), and there is no dedicated "copywriting" routine. The real
// capability here is Company News, which surfaces a genuine recent event;
// the "personalization" itself is a template applied to that real fact
// (swap in your own LLM call here for freer-form phrasing -- Clay's job
// ends at supplying the real fact, not writing the sentence).
async function fetchRecentNewsEvent(domain: string): Promise<NewsEvent | null> {
const startRes = await clayFetch(`/routines/${COMPANY_NEWS_ROUTINE_ID}/run`, {
method: "POST",
body: JSON.stringify({
items: [{ id: domain, inputs: { "Company Domain": domain, "Max News Events": "3" } }],
}),
});
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?: CompanyNewsResult }>;
};
if (body.status === "complete") {
const events = body.data[0]?.result?.["Find Most Recent News"]?.events ?? [];
return events[0] ?? null;
}
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`routine run ${routine_run_id} did not complete in time`);
}
// A template applied to the real fact above -- illustrative, not an LLM
// call. Swap this for a real LLM API call (passing the same real event data
// as context) for freer-form generated phrasing.
function draftOpeningLine(companyName: string, event: NewsEvent | null): string {
if (!event || !event.summary) {
return `I've been following ${companyName}'s work and wanted to reach out.`;
}
return `Saw that ${companyName} was recently in the news: "${event.summary}" -- wanted to reach out given the timing.`;
}
const app = express();
app.use(express.json());
app.post("/draft-opener", async (req: Request, res: ExpressResponse) => {
const { companyName, companyDomain } = req.body ?? {};
if (!companyName || !companyDomain) {
return res.status(400).json({ error: "companyName and companyDomain are required" });
}
try {
const event = await fetchRecentNewsEvent(companyDomain);
res.json({
newsEvent: event,
openingLine: draftOpeningLine(companyName, event),
});
} 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/draft-opener \
-H "Content-Type: application/json" \
-d '{"companyName":"Stripe","companyDomain":"stripe.com"}'
6. Verify the result
{
"newsEvent": {
"summary": "Fireblocks Inc. identified as competitor of Stripe, Inc. on Aug 28th '26.",
"category": "identified_as_competitor_of",
"found_at": "2026-08-28T12:23:47Z"
},
"openingLine": "Saw that Stripe was recently in the news: \"Fireblocks Inc. identified as competitor of Stripe, Inc. on Aug 28th '26.\" -- wanted to reach out given the timing."
}
A real, dated news event was fetched and turned into a factually-grounded opener.
How it works
"AI-personalized copy" conflates two separate things: finding a real, timely fact to personalize with (which Clay's Company News routine does), and writing natural-sounding prose from that fact (which is either a template, as shipped here, or a separate LLM call the caller makes — Clay has no hand in the writing itself). Treating "Claygent" as a callable REST function for this is the core mistake; it simply isn't part of this API surface.
Common issues
Trying to call a Claygent endpoint for copy generation
Cause: the content plan's own language ("use Claygent") and Clay's marketing around Claygent as an AI research agent.
Fix: Claygent is not part of the Public REST API at all (it's not in the 13-path OpenAPI spec, and requires clay workflows tooling). Use Company News for the real fact and your own template or LLM call for the phrasing.
Reading result["Company News"] and expecting article data
Cause: assuming the top-level key matching the routine's display name holds the useful content.
Fix: the real event data is under "Find Most Recent News" — "Company News" is a separate, less useful string field (a count).
Next steps
- Swap the template for a real LLM API call (e.g. Claude or GPT), passing the same real
summary/article_sentenceas grounding context, for more natural phrasing. - Combine with the funding-qualification or tech-stack examples for multi-fact personalization.
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/draft-opener -d '{\"companyName\":\"Stripe\",\"companyDomain\":\"stripe.com\"}'"
expected_result: "A real, dated news event is fetched for a real domain and used to draft a factually-grounded opening line."