Find customers
A short daily batch of new ICP people lands on your Attio list, with a Slack ping when they're in.
- onDaily cron at 9:00 AM ET
- agentcompose Apollo search from ICP description
- apollosearch people with email
- apollobulk enrich the batch
- attioskip anyone already in CRM
- attioupsert people onto the list
- slackpost the summary
durable — pauses for approvals, survives restarts
Most prospecting stacks bury the search logic in a UI. This job keeps it in code: describe your ICP, let an agent build the Apollo filters, enrich a small batch, skip anyone already in Attio, and land the rest on a list with a Slack summary.
Prerequisites
- A Terse project (
terse init) with the Apollo, Attio, and Slack integrations connected (terse integrate). - An Attio list whose slug matches
LIST_SLUGbelow. - A Slack channel mapped in
terse.generatedforNOTIFY_CHANNEL.
Customize
Fill these in at the top of the job before you deploy:
ICP_DESCRIPTION— plain-English ICPLIST_SLUG— Attio list api slugNOTIFY_CHANNEL— Slack channel enum fromterse.generatedBATCH_SIZE— how many new people to add per run
The job
typescript
import { createJob, generateText, log } from "terse-sdk";
import { z } from "zod";
import {
AttioObject,
SlackChannel,
Triggers,
toolbox,
type ApolloBulkEnrichPeopleResult,
} from "../terse.generated";
// ── Fill these in ───────────────────────────────────────────────
const ICP_DESCRIPTION =
"Technical people who build systems for marketing and sales at early-stage companies.";
const LIST_SLUG = "icp_prospects_template";
const NOTIFY_CHANNEL = SlackChannel.TestAgents;
const BATCH_SIZE = 5;
createJob({
name: "Prospect ICP people (template)",
triggers: [
Triggers.schedule.cron({
expression: "0 9 * * *",
timezone: "America/New_York",
}),
],
durable: true,
onTrigger: async () => {
const list = await toolbox.attio.getList({ list: LIST_SLUG });
const search = await generateText({
prompt:
`Compose an Apollo people search for this ICP:\n${ICP_DESCRIPTION}\n` +
`Return Apollo filter fields only. Headcount ranges MUST be "min,max" ` +
`strings (e.g. "1,10", "11,50") — never "1-10".`,
skills: [],
outputSchema: ApolloSearchPlan,
});
const { people } = await toolbox.apollo.searchPeople({
personTitles: search.personTitles,
includeSimilarTitles: search.includeSimilarTitles,
organizationNumEmployeesRanges:
search.organizationNumEmployeesRanges?.map((range) =>
range.replace("-", ","),
),
perPage: 25,
page: 1,
});
const candidates = people
.filter((person) => person.hasEmail === true)
.slice(0, BATCH_SIZE);
if (candidates.length === 0) {
await notifyDone("Apollo returned no people with email to enrich.");
return;
}
const { matches } = await toolbox.apollo.bulkEnrichPeople({
people: candidates.map((person) => ({ id: person.id })),
});
const prospects = await selectNewProspects(matches, BATCH_SIZE);
if (prospects.length === 0) {
await notifyDone("Everyone found was already in Attio.");
return;
}
await Promise.all(
prospects.map((person) => upsertProspect(person, list.api_slug)),
);
await notifyDone(formatSummary(prospects, list.name));
await log("Prospecting complete", {
added: prospects.length,
list: list.api_slug,
});
},
});
async function selectNewProspects(
matches: ApolloBulkEnrichPeopleResult["matches"],
limit: number,
): Promise<EnrichedPerson[]> {
const selected: EnrichedPerson[] = [];
for (const person of matches) {
if (selected.length >= limit) break;
if (!person.email) continue;
const { records } = await toolbox.attio.queryRecords({
object: AttioObject.Person,
filter: { email_addresses: person.email },
limit: 1,
});
if (records.length > 0) continue;
selected.push({ ...person, email: person.email });
}
return selected;
}
async function upsertProspect(
person: EnrichedPerson,
list: string,
): Promise<void> {
const { records } = await toolbox.attio.upsertRecord({
object: AttioObject.Person,
matchingAttribute: "email_addresses",
records: [
{
email_addresses: [person.email],
name:
person.name ??
([person.firstName, person.lastName].filter(Boolean).join(" ") ||
undefined),
job_title: person.title ?? undefined,
linkedin: person.linkedinUrl ?? undefined,
},
],
});
const recordId = records[0]?.id?.record_id ?? records[0]?.record_id;
if (!recordId) {
throw new MissingProspectRecordIdError(person.email);
}
await toolbox.attio.upsertListEntry({
list,
parentRecordId: recordId,
parentObjectSlug: "people",
});
}
async function notifyDone(message: string): Promise<void> {
await toolbox.slack.sendMessage({
channelId: NOTIFY_CHANNEL.channelId,
message,
thread_ts: "",
blocks: null,
});
}
function formatSummary(prospects: EnrichedPerson[], listName: string): string {
const lines = prospects.map((person) => {
const name =
person.name ??
([person.firstName, person.lastName].filter(Boolean).join(" ") ||
person.email);
const title = person.title ?? "Unknown title";
const company = person.organization?.name ?? "Unknown company";
return `• *${name}* — ${title} @ ${company} (\`${person.email}\`)`;
});
return (
`*ICP prospecting complete* — added ${prospects.length} ` +
`${prospects.length === 1 ? "person" : "people"} to ${listName}\n\n` +
lines.join("\n")
);
}
class MissingProspectRecordIdError extends Error {
constructor(email: string) {
super(`Upserted prospect ${email} but Attio returned no record id`);
this.name = "MissingProspectRecordIdError";
}
}
const ApolloSearchPlan = z.object({
personTitles: z.array(z.string()).min(1),
includeSimilarTitles: z.boolean(),
organizationNumEmployeesRanges: z
.array(z.string())
.nullable()
.describe('Headcount ranges as "min,max", e.g. ["1,10", "11,50"].'),
});
type EnrichedPerson = ApolloBulkEnrichPeopleResult["matches"][number] & {
email: string;
};
PostHog