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.
- Kind
- Workflow
- Price
- 87¢ per 300 terms
- Your time
- 10 minutes
- Approval
- Runs on its own
- Deepens
- Content marketing
Find 20 winnable keywords for salesbuilder.dev, spend up to $1, and show me the plan first.
curl -X POST "https: //api.sb/ leaseline.app/ keywords/ map? mode= try" \
-H "Authorization: Bearer $SB_TOKEN" \
-H "Content-Type: application/ json" \
-d '{"goal": "Find 20 winnable keywords for salesbuilder.dev, spend up to $1, and show me the plan first.", "budget": {"amount": 1, "currency": "USD"}}'
npx startup-builder do Keyword.map --try --data '{"goal": "Find 20 winnable keywords for salesbuilder.dev, spend up to $1, and show me the plan first.", "budget": {"amount": 1, "currency": "USD"}}'
{
"name": "do",
"arguments": {
"mode": "try",
"code": "return await $.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' } })"
}
}
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' } })
Keyword.findon 5 seeds, with search-box suggestions where the seeds come back thin.- Shortlist the terms a buyer would type.
Keyword.measuredifficulty for the shortlist, in one batch.Keyword.searchfor the top 25.Domain.benchmarkfor the top 5 domains on those results pages.- Score each term on difficulty, the domains it must beat and intent.
Planning is free. You see the plan and its price before anything runs.
Decisions
- D1, rows 8 to 16switch from related terms to phrase match and search-box suggestions.Related terms had no reading for founder phrasing such as “how to get first customers”. Phrase match and suggestions returned 205 rows and 52 suggestions for the same words.
- D2, row 17measure the 63-term shortlist in one batch.Difficulty is priced by the batch of up to 1,000 terms, so 63 terms cost one batch. The 19 terms with no reading cost nothing.
- D3, row 78read salesbuilder.dev’s own authority as the baseline.At an authority of 1, a term is winnable only where page one already holds weak or user-written pages, which is what the soft slots column counts.
By call
| Call | Calls | Cost | Outcomes |
|---|---|---|---|
Keyword.find | 10 | $0.004 | 8 listed · 2 no reading |
Keyword.find({ mode: 'suggestions' }) | 6 | $0.0051 | 6 listed |
Keyword.measure | 1 | $0.022 | 1 measured |
Keyword.search | 28 | $0.0042 | 28 read |
Domain.benchmark | 33 | $0.08861 | 33 read |
| Spent | 78 | $0.12 | of $1.00, $0.88 left |
Agent time 6 minutes, in one sitting.
Every call, 78 rows
Loading the rows.
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. · Keyword
term· The search, as people type it.searches· Searches a month in the locale;nullwhen 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), orboth.
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.
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 | 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 | 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 | 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 | 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 | 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
Each failure, and what happens next
| 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:, 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:. |
| 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 |
| Add the questions people ask on Reddit about rent tracking as seeds. | Keyword.search |
| Write a page for the five most winnable terms. | Page.answer |
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: and base(), or compose your own from these steps.
Works with
Plays that order it: Category hubs, Category kit, Editorial SEO, Free tools, Paid search, Programmatic SEO.
It serves Research demand, and runs in Launch and search, Paid. It deepens Content marketing.
For agents
For agents: the guide, its approvals, retries and lookups
| 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: |
| 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 |
|---|
| 1. Pull keyword ideas from the seeds |
| 2. Measure volume and cost per click |
| 3. Measure difficulty in one batch |
| 4. Read the results page for the top 40 |
| 5. Measure who ranks in each top 5, and your site |
| 6. Score each term by the declared formula |
The amounts live once, on the hub (approvals).
Who does each step, and how it is checked
| 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, |
The doer is never the checker, and the weakest assurance on the path is the whole call’s.
Under the hood
Under the hood: the Systems, Nouns and Events it touches
| Kind | What it touches |
|---|---|
| Systems | None. |
| Nouns | Keyword, Domain, Collection |
| Events | Keyword.measured, Domain.benchmarked, Collection.queried |
Reference
Inputs, outputs, its Event and its errors
| 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 before it spends, and with the Data calls and Accounts groups (refusals).