URL: /capabilities/research-demand/keyword-opportunity-map · Id: `page:/capabilities/research-demand/keyword-opportunity-map`

# Keyword opportunity map

> Three seed phrases in, 300 search terms out, each with volume, difficulty, who ranks and a score for whether a new site can win it.

Three seed phrases in, 300 search terms out, each with volume, difficulty, who ranks and a score for whether a new site can win it.

```text title="Run it · Ask"
Find 20 winnable keywords for salesbuilder.dev, spend up to $1, and show me the plan first.
```

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

const result = await $.try.Keyword.map({ goal: 'Find 20 winnable keywords for salesbuilder.dev, spend up to $1, and show me the plan first.', budget: { amount: 1, currency: 'USD' } })
```

**Plan.** (1) `Keyword.find` on 5 seeds, with search-box suggestions where the seeds come back thin. (2) Shortlist the terms a buyer would type. (3) `Keyword.measure` difficulty for the shortlist, in one batch. (4) `Keyword.search` for the top 25. (5) `Domain.benchmark` for the top 5 domains on those results pages. (6) Score each term on difficulty, the domains it must beat and intent. Estimate 29¢ of $1.00.

**Receipt.** 78 calls, 12¢ spent, 20 of 20 keywords.

- **Kind:** Workflow
- **Price:** 87¢ per 300 terms
- **Your time:** 10 minutes
- **Approval:** Runs on its own
- **Deepens:** [`process:content-marketing`](https://startupbuilder.dev/workflows/processes/content-marketing)

## What you get
- **300 terms** · Three seed phrases in, 300 search terms out, each with volume, difficulty, who ranks and a score for whether a new site can win it. · [`noun:keyword`](https://startupbuilder.dev/business/nouns/keyword)
- `term` · The search, as people type it.
- `searches` · Searches a month in the locale; `null` when too few to measure, never zero.
- `difficulty` · How hard the top 10 is to break into, 0 to 100. Under 20 is open to a new site; over 40 needs links you do not have yet.
- `topAuthority` · The median and the weakest authority among the five sites that rank first. A weakest slot under your own authority plus 15 is a page you can displace.
- `softSlots` · How many of the top five are forum threads, videos or other pages anyone can post, out of 5.
- `winnability` · The declared score, 0 to 100: volume × (1 − difficulty ÷ 100) × the authority gap, plus 10 when a forum or a weaker page ranks in the top 5.
- `route` · `earn` (write for it), `buy` (its cost per click is under your break-even), or `both`.

**How long:** About 2 minutes; the results pages read 10 at a time.

## Trigger
It runs when you call it. Monthly search content re-runs it on the first of each month.

```ts
every.month(($) => $.Keyword.map({ seeds: ['rent tracking spreadsheet', 'lease renewal reminders'], site: 'leaseline.app' }))
```

## 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. Pull keyword ideas from the seeds | [`Keyword.find`](/code/data/keywords/find) | 300 related terms, with the seeds’ own suggestions where the related graph is thin | About 15¢ | Runs on its own |
| 2. Measure volume and cost per click | [`Keyword.measure`](/code/data/keywords/measure) | Searches and CPC for each term; long-tail rows can come back empty | About 15¢ | Runs on its own |
| 3. Measure difficulty in one batch | [`Keyword.measure`](/code/data/keywords/measure) | A difficulty for each term, 0 to 100, for up to 1,000 terms in one call | About 1.2¢ | Runs on its own |
| 4. Read the results page for the top 40 | [`Keyword.search`](/code/data/keywords/search) | The top 10 organic results for each, with the forum and video slots marked | About 1.2¢ | Runs on its own |
| 5. Measure who ranks in each top 5, and your site | [`Domain.benchmark`](/code/data/domains/benchmark) | Authority for about 180 sites, one row per hostname | About 54¢ | Runs on its own |
| 6. Score each term by the declared formula | `Collection.query` | Winnability and route for every term, sorted | Free | Runs on its own |

Calls: `Keyword.find`, then `Keyword.measure`, then `Keyword.search`, then `Domain.benchmark`, then `Collection.query`.

About 87¢ per 300 terms, each time it runs, capped at $1.25.

## Example: give it a goal
> Find 20 winnable keywords for salesbuilder.dev, spend up to $1, and show me the plan first.

Run it above shows the plan it showed before it spent anything, and the receipt of every call.

## When something fails

| When | Then |
| --- | --- |
| A seed returns no ideas | It is skipped with `no-ideas`, and the seed’s own search suggestions run instead at about 0.1¢ a seed; the founder phrasing in G2 needed this for 3 of 7 seeds. |
| A long-tail term has too few searches to measure | It reads `searches: null`, shown as unknown, never as zero; it can still score on its results page. |
| The results page cannot be read on any route | The term keeps its measures and gets `winnability: null`. |
| You call it again the same day with the same seeds | It returns the stored map at $0: it is a read, keyed on seeds, site, locale 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-keyword-map-1-<step>-<item>`.

## Change it
| Ask | Compiles to |
| --- | --- |
| Only keep terms under difficulty 30 with at least 100 searches. | [`Keyword.map`](/capabilities/research-demand/keyword-opportunity-map) |
| Add the questions people ask on Reddit about rent tracking as seeds. | [`Keyword.search`](/code/data/keywords/search) |
| Write a page for the five most winnable terms. | [`Page.answer`](/capabilities/make-content/answer-pages) |

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

export default workflow({
  name: 'keyword-map',
  verb: 'Keyword.map',
  input: { seeds: 'string[]', site: 'string', locale: 'string', results: 'integer' },
  maxCost: 1.25,
  execute: async (ctx, { seeds, site, locale = 'en-US', ideas = 300, results = 40 }) => {
    const found = await ctx.step('ideas', () => $.Keyword.find({ seeds, locale, limit: ideas, maxCost: 0.2 }))
    const rows = await ctx.step('measure', () => $.Keyword.measure({ keywords: found.terms, locale, maxCost: 0.2 }))
    const kd = await ctx.step('difficulty', () => $.Keyword.measure({ keywords: found.terms, measures: ['difficulty'], maxCost: 0.02 }))
    const top = [...rows].sort((a, b) => (b.searches ?? 0) - (a.searches ?? 0)).slice(0, results)
    const pages = await ctx.step('results', () => $.Keyword.search({ keywords: top.map((t) => t.term), venue: 'google-search', maxCost: 0.02 }))
    const hosts = [...new Set(pages.flatMap((p) => p.results.slice(0, 5).map((r) => r.hostname))), site]
    const auth = await ctx.step('authority', () => $.Domain.benchmark({ hostnames: hosts, measures: ['authority'], maxCost: 0.6 }))
    return score(rows, kd, pages, auth, site) // the declared formula: volume × (1 − difficulty ÷ 100) × authority gap
  },
})
```

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

## Works with
**Plays that order it:** [Category hubs](/plays/category-hubs), [Category kit](/plays/category-kit), [Editorial SEO](/plays/editorial-seo), [Free tools](/plays/free-tools), [Paid search](/plays/paid-search), [Programmatic SEO](/plays/programmatic-seo).

It serves [Research demand](/capabilities/research-demand), and runs in [Launch and search](/strategy/launch-and-search), [Paid](/strategy/paid). It deepens [`process:content-marketing`](https://startupbuilder.dev/workflows/processes/content-marketing).

## For agents

| Fact | Value |
| --- | --- |
| Guide | `Keyword.map` |
| Guide approval | `limits` |
| Goal | Find 20 winnable keywords for salesbuilder.dev, spend up to $1, 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 | `every.month(($) => $.Keyword.map({ seeds: ['rent tracking spreadsheet', 'lease renewal reminders'], site: 'leaseline.app' }))` |
| Retry | Three times with backoff, then the next route; the same idempotency key never charges, sends or posts twice |
| Idempotency key | `leaseline-keyword-map-1` |
| Lookups | A `find:` input asks unless exactly one record fits |

| Step | Action class | Seat rule that would hold it |
| --- | --- | --- |
| 1. Pull keyword ideas from the seeds | None | None. |
| 2. Measure volume and cost per click | None | None. |
| 3. Measure difficulty in one batch | None | None. |
| 4. Read the results page for the top 40 | None | None. |
| 5. Measure who ranks in each top 5, and your site | None | None. |
| 6. Score each term by the declared formula | None | None. |

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

| Step | Function | Doer | Checker | Assurance | Inputs |
| --- | --- | --- | --- | --- | --- |
| 1. Pull keyword ideas from the seeds | `code` | Code | Code checks each result against its declared type | `measured` | `$0.seeds` |
| 2. Measure volume and cost per click | `code` | Code | Code checks each result against its declared type | `measured` | `$1.terms` |
| 3. Measure difficulty in one batch | `code` | Code | Code checks each result against its declared type | `measured` | `$1.terms` |
| 4. Read the results page for the top 40 | `code` | Code | Code checks each result against its declared type | `measured` | `$2.top` |
| 5. Measure who ranks in each top 5, and your site | `code` | Code | Code checks each result against its declared type | `measured` | `$4.hostnames` |
| 6. Score each term by the declared formula | `code` | Code | Code checks each result against its declared type | `measured` | `$2, $3, $4, $5` |

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 | None. |
| Nouns | [`noun:keyword`](https://startupbuilder.dev/business/nouns/keyword), [`noun:domain`](https://startupbuilder.dev/business/nouns/domain), [`noun:collection`](https://startupbuilder.dev/business/nouns/collection) |
| Events | `Keyword.measured`, `Domain.benchmarked`, `Collection.queried` |

## Reference

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `seeds` | `string[]` | Required | The problem in the buyer’s words, two to five phrases. Your category name is the weakest seed: a founder with no customers ranks first for problems, not for categories. |
| `site` | `Hostname` | Required | Your site. Its own authority anchors the score. |
| `locale` | `string` | `'en-US'` | The market the terms are measured in. |
| `ideas` | `number` | `300` | How many ideas to pull before measuring. |
| `results` | `number` | `40` | How many top terms get their results page read. |
| `where` | `object` | Required | Optional hard filters: `maxDifficulty`, `minVolume`, `intent`. |
| `maxCost` | `number` | `1.25` | 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 `Keyword.mapped`. Refuses with [`max-cost`](/code/refusals#max-cost) before it spends, and with the Data calls and Accounts groups ([refusals](/code/refusals)).