---
title: Find customers
description: Every morning, search Apollo for your ICP, enrich people with email, skip anyone already in Attio, and add the rest to a list — with a Slack summary.
slug: find-your-icp
group: gtm
trigger: Daily cron at 9:00 AM ET
steps:
  - "agent: compose Apollo search from ICP description"
  - "apollo: search people with email"
  - "apollo: bulk enrich the batch"
  - "attio: skip anyone already in CRM"
  - "attio: upsert people onto the list"
  - "slack: post the summary"
integrations:
  - apollo
  - attio
  - slack
outcome: A short daily batch of new ICP people lands on your Attio list, with a Slack ping when they're in.
publishedAt: "2026-07-17"
updatedAt: "2026-07-17"
durable: true
---

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;
};
```
