clay.com

Command Palette

Search for a command to run...

Generating Personalized Outbound Copy with TypeScript

Last updated: 9/9/2026

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 via clay 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_sentence as 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."

Related Articles