URL: /capabilities/find-customers/post-engagers · Id: `page:/capabilities/find-customers/post-engagers`

# Post-engager harvest

> Posts about your problem in, the people who commented on them out, filtered to your buyer’s titles, each with the post they engaged on.

[Posts](https://startupbuilder.dev/business/nouns/post) about your problem in, the people who commented on them out, filtered to your buyer’s titles, each with the post they engaged on.

```text title="Run it · Ask"
Find the property managers who engaged with posts about rent spreadsheets this month, spend up to $2, and show me the plan first.
```

```ts title="Run it · SDK"
import { $ } from 'startup-builder'

const result = await $.try.Audience.harvest({ goal: 'Find the property managers who engaged with posts about rent spreadsheets this month, spend up to $2, and show me the plan first.', budget: { amount: 2, currency: 'USD' } })
```

```json title="Run it · Response"
{
  "mode": "try",
  "stored": false,
  "estimate": "About 56¢ per 200 people",
  "sample": {
    "person": "Maya C.",
    "title": "Operations Lead",
    "company": "Cedar Hollow Rentals",
    "engaged on": "a post on rent spreadsheets",
    "how": "comment"
  }
}
```

- **Kind:** Workflow
- **Price:** 56¢ per 200 people
- **Your time:** 7 minutes
- **Approval:** Runs on its own
- **Deepens:** [`process:outbound`](https://startupbuilder.dev/workflows/processes/outbound)

## What you get
- **200 people** · posts about your problem in, the people who commented on them out, filtered to your buyer’s titles, each with the post they engaged on. · [`noun:audience`](https://startupbuilder.dev/business/nouns/audience)
- `audience` · A warm Audience, ready for Personal first touch or a LinkedIn message.
- `people[]` · Name, title, company, the post they engaged on and how: comment or like.
- `bySource` · Kept people per post: the posts that draw your buyer, for your own next post.

**Connect once:** [LinkedIn](/code/integrations/linkedin). **How long:** About 5 minutes.

## Trigger
It runs weekly, and two days after each of your own posts goes up.

```ts
on.Post.published((post, $) => post.handle.startsWith('linkedin:') && $.Audience.harvest({ posts: { urls: [post.url] }, take: 50 }))
```

## Steps
Your agent does these steps, one row per step; each step’s check is part of its row and its price.

| Step | Verb | Returns | Price | Approval |
| --- | --- | --- | --- | --- |
| 1. Find posts that use your problem phrases | [`Keyword.search`](/code/data/keywords/search) | Recent LinkedIn posts on the problem, by comments | About 1¢ | Runs on its own |
| 2. Read who engaged with each post | [`Prospect.find`](/code/data/people/find) | The people who commented on or liked each post | About 15¢ | Runs on its own |
| 3. Keep your buyer’s titles and company sizes | `Collection.query` | The people who pass the declared filter | Free | Runs on its own |
| 4. Read each kept person’s profile | [`Contact.enrich`](/code/data/people/enrich) | Title, employer, seniority and location, current | About 40¢ | Runs on its own |
| 5. Save them as a warm Audience | [`Audience.create`](/code/apps/ads/create-audience) | One Audience, each row with the post it came from | Free | Runs on its own |

Calls: `Keyword.search`, then `Prospect.find`, then `Collection.query`, then `Contact.enrich`, then `Audience.create`.

About 56¢ per 200 people, each time it runs, capped at $1.50.

## Example: give it a goal
> Find the property managers who engaged with posts about rent spreadsheets this month, spend up to $2, and show me the plan first.

The plan it shows runs 5 steps, priced before it spends anything; Run it above shows the result it returns.

## When something fails

| When | Then |
| --- | --- |
| A post hides its engagement | It is skipped with `engagement-hidden`; the read costs nothing. |
| The LinkedIn connection needs signing in again | Only the own-posts part stops, with the fix line; the phrase search still runs. |
| New posts add under 5% new people | In goal mode the agent stops reading posts, as a Decision: more posts would buy the same people. |
| You call it twice in one day for one post | The second call returns the stored people at $0: it is idempotent per post and day. |

Each step retries three times, then falls through to the next route; one item's failure lands in `skipped` with its reason, never failing the call. Before a step would cross `maxCost` it stops and returns what it has, with a `resume` link. Each step's idempotency key is the call's key plus the step and the item: `leaseline-audience-harvest-1-<step>-<item>`.

## Change it
| Ask | Compiles to |
| --- | --- |
| Count likes too, not only comments. | [`Audience.harvest`](/capabilities/find-customers/post-engagers) |
| Write each of them a first message that mentions their comment. | [`Prospect.reach`](/capabilities/reach-out/personal-first-touch) |
| Harvest my own posts every week. | [`Audience.harvest`](/capabilities/find-customers/post-engagers) |

```ts
import { $, workflow } from 'startup-builder'

export default workflow({
  name: 'audience-harvest',
  verb: 'Audience.harvest',
  input: { posts: '{ phrases?: string[]; own?: boolean; urls?: string[] }', how: "('comment' | 'like')[]", take: 'integer' },
  maxCost: 1.5,
  execute: async (ctx, { posts = { own: true }, how = ['comment'], icp = 'icps/icp_4', take = 200 }) => {
    const found = posts.phrases ? await ctx.step('posts', () => $.Keyword.search({ keywords: posts.phrases, venue: 'linkedin', sort: 'comments', maxCost: 0.02 })) : { results: [] }
    const urls = [...found.results.map((r) => r.url), ...(posts.urls ?? [])]
    const who = await ctx.step('engagers', () => $.Prospect.find({ engagedWith: urls, own: posts.own, how, maxCost: 0.2 }))
    const kept = await ctx.step('filter', () => $.Collection.query({ rows: who.prospects, matches: icp, limit: take }))   // a declared filter
    const people = await ctx.step('profiles', () => $.Contact.enrich({ contacts: kept.rows.map((p) => p.$id), fields: ['title', 'organization'], maxCost: 0.5 }))
    return ctx.step('save', () => $.Audience.create({ name: 'Warm · engaged', members: people }))
  },
})
```

Extend it with `extends: 'Audience.harvest'` and `base()`, or [compose your own](/code/compose) from these steps.

## Works with
**Plays that order it:** [Creator partnership](/plays/creator-partnership), [Test with a creator](/plays/creator-probe), [Creator retainers](/plays/creator-retainers), [Engagement bridge](/plays/engagement-bridge), [Events and webinars](/plays/events-and-webinars), [Hosted gatherings](/plays/gatherings), [Tools on request](/plays/request-desk).

It serves [Find your first customers](/capabilities/find-customers), and runs in [Hands-on](/strategy/hands-on). It deepens [`process:outbound`](https://startupbuilder.dev/workflows/processes/outbound).

## For agents

| Fact | Value |
| --- | --- |
| Guide | `Audience.harvest` |
| Guide approval | `limits` |
| Goal | Find the property managers who engaged with posts about rent spreadsheets this month, spend up to $2, and show me the plan first. It may change counts, the order of steps, soft filters and the route a read takes, and it stops early when the goal is met. It never relaxes your words, raises its own budget, answers its own approval, calls a Play or acts outside your standing approval. |
| Trigger | `on.Post.published((post, $) => post.handle.startsWith('linkedin:') && $.Audience.harvest({ posts: { urls: [post.url] }, take: 50 }))` |
| Retry | Three times with backoff, then the next route; the same idempotency key never charges, sends or posts twice |
| Idempotency key | `leaseline-audience-harvest-1` |
| Lookups | A `find:` input asks unless exactly one record fits |

| Step | Action class | Seat rule that would hold it |
| --- | --- | --- |
| 1. Find posts that use your problem phrases | None | None. |
| 2. Read who engaged with each post | None | None. |
| 3. Keep your buyer’s titles and company sizes | None | None. |
| 4. Read each kept person’s profile | None | None. |
| 5. Save them as a warm Audience | None | None. |

The amounts live once, on the hub ([approvals](https://startupbuilder.dev/agents/approvals)).

| Step | Function | Doer | Checker | Assurance | Inputs |
| --- | --- | --- | --- | --- | --- |
| 1. Find posts that use your problem phrases | `code` | Code | Code checks each result against its declared type | `measured` | `$0.posts.phrases` |
| 2. Read who engaged with each post | `code` | Code | Code checks each result against its declared type | `measured` | `$1.posts, $0.posts.urls` |
| 3. Keep your buyer’s titles and company sizes | `code` | Code | Code checks each result against its declared type | `measured` | `$2.people, $0.icp` |
| 4. Read each kept person’s profile | `code` | Code | Code checks each result against its declared type | `measured` | `$3.people` |
| 5. Save them as a warm Audience | `code` | Code | Code reads the result back from where it landed | `measured` | `$4, $0.take` |

The doer is never the checker, and the weakest assurance on the path is the whole call’s.

## Under the hood

| Kind | What it touches |
| --- | --- |
| Systems | [Ads](/code/apps/ads), [CRM](/code/apps/crm) |
| Nouns | [`noun:keyword`](https://startupbuilder.dev/business/nouns/keyword), [`noun:prospect`](https://startupbuilder.dev/business/nouns/prospect), [`noun:collection`](https://startupbuilder.dev/business/nouns/collection), [`noun:contact`](https://startupbuilder.dev/business/nouns/contact), [`noun:audience`](https://startupbuilder.dev/business/nouns/audience) |
| Events | `Collection.queried`, `Contact.enriched`, `Audience.created` |

## Reference

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `posts` | `{ phrases?: string[]; own?: boolean; urls?: Url[] }` | `{ own: true }` | Where to harvest: LinkedIn posts that use your problem phrases, your own posts, or posts you name. |
| `how` | `('comment' \| 'like')[]` | `['comment']` | Which engagement counts. Commenters wrote something you can answer; a like is a weaker sign. |
| `icp` | `ICP` | `your saved ICP` | The titles and company sizes kept, as a declared filter. |
| `take` | `integer` | `200` | People to keep. |
| `maxCost` | `number` | `1.50` | The most this call spends each time it runs, in USD. It stops before a step would cross it and returns what it has, with a `resume` link. |

Emits `Audience.harvested`. Refuses with [`max-cost`](/code/refusals#max-cost) before it spends, and with the Data calls and Accounts groups ([refusals](/code/refusals)).