Find customers

A short daily batch of new ICP people lands on your Attio list, with a Slack ping when they're in.

  1. onDaily cron at 9:00 AM ET
  2. agentcompose Apollo search from ICP description
  3. apollosearch people with email
  4. apollobulk enrich the batch
  5. attioskip anyone already in CRM
  6. attioupsert people onto the list
  7. slackpost the summary

durable — pauses for approvals, survives restarts

Build it with your coding agent

Paste into any coding agent with the Terse skills installed. No skills yet? Install them in one command.

/terse-create build https://www.useterse.ai/use-cases/find-your-icp.md

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

  1. A Terse project (terse init) with the Apollo, Attio, and Slack integrations connected (terse integrate).
  2. An Attio list whose slug matches LIST_SLUG below.
  3. A Slack channel mapped in terse.generated for NOTIFY_CHANNEL.

Customize

Fill these in at the top of the job before you deploy:

  • ICP_DESCRIPTION — plain-English ICP
  • LIST_SLUG — Attio list api slug
  • NOTIFY_CHANNEL — Slack channel enum from terse.generated
  • BATCH_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;
};