Skip to content

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
Keyword.map
You, to your agent

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' } })
  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$0.29 of $1.00

Planning is free. You see the plan and its price before anything runs.

Try mode reads, stores nothing and spends nothingAPI referenceIts Nouns

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; 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.

TypeScript
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.

StepVerbReturnsPriceApproval
1. Pull keyword ideas from the seedsKeyword.find300 related terms, with the seeds’ own suggestions where the related graph is thinAbout 15¢Runs on its own
2. Measure volume and cost per clickKeyword.measureSearches and CPC for each term; long-tail rows can come back emptyAbout 15¢Runs on its own
3. Measure difficulty in one batchKeyword.measureA difficulty for each term, 0 to 100, for up to 1,000 terms in one callAbout 1.2¢Runs on its own
4. Read the results page for the top 40Keyword.searchThe top 10 organic results for each, with the forum and video slots markedAbout 1.2¢Runs on its own
5. Measure who ranks in each top 5, and your siteDomain.benchmarkAuthority for about 180 sites, one row per hostnameAbout 54¢Runs on its own
6. Score each term by the declared formulaCollection.queryWinnability and route for every term, sortedFreeRuns 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
WhenThen
A seed returns no ideasIt 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 measureIt 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 routeThe term keeps its measures and gets winnability: null.
You call it again the same day with the same seedsIt 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

AskCompiles 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
TypeScript
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 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
FactValue
GuideKeyword.map
Guide approvallimits
GoalFind 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.
Triggerevery.month(($) => $.Keyword.map({ seeds: ['rent tracking spreadsheet', 'lease renewal reminders'], site: 'leaseline.app' }))
RetryThree times with backoff, then the next route; the same idempotency key never charges, sends or posts twice
Idempotency keyleaseline-keyword-map-1
LookupsA 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
StepFunctionDoerCheckerAssuranceInputs
1. Pull keyword ideas from the seedscodeCodeCode checks each result against its declared typemeasured$0.seeds
2. Measure volume and cost per clickcodeCodeCode checks each result against its declared typemeasured$1.terms
3. Measure difficulty in one batchcodeCodeCode checks each result against its declared typemeasured$1.terms
4. Read the results page for the top 40codeCodeCode checks each result against its declared typemeasured$2.top
5. Measure who ranks in each top 5, and your sitecodeCodeCode checks each result against its declared typemeasured$4.hostnames
6. Score each term by the declared formulacodeCodeCode checks each result against its declared typemeasured$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

Under the hood: the Systems, Nouns and Events it touches
KindWhat it touches
SystemsNone.
NounsKeyword, Domain, Collection
EventsKeyword.measured, Domain.benchmarked, Collection.queried

Reference

Inputs, outputs, its Event and its errors
NameTypeDefaultMeaning
seedsstring[]RequiredThe 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.
siteHostnameRequiredYour site. Its own authority anchors the score.
localestring'en-US'The market the terms are measured in.
ideasnumber300How many ideas to pull before measuring.
resultsnumber40How many top terms get their results page read.
whereobjectRequiredOptional hard filters: maxDifficulty, minVolume, intent.
maxCostnumber1.25The 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).

Contents