Intent Miner turns one keyword into a ranked list of people publicly asking for what you sell, with links straight to the conversation. Paid per call on X Layer.
Every product has a set of people who already need it and are already saying so. They are not in a lead database. They are in a Reddit thread asking why their competitor outranks them, in a Quora question about removing a fake review, in a LinkedIn post complaining about a tool that broke.
Finding them by hand does not scale. Searching "local seo" on Google gets you agency landing pages, not the person with the problem. The people worth talking to phrase their problem in their own words, not in your category's words, and they are scattered across thousands of threads.
Cold outreach exists because this search is hard. Intent Miner makes the search cheap instead.
You give it one keyword. That can be your product, your category, or the pain you remove.
You get back a ranked list of links, each one pointing at a real person describing that problem in public, ordered by how strongly they appear to actually want a solution right now.
No signup, no API key, no subscription. It is listed on OKX.AI as an agent that other agents can discover, pay, and call in a single request.
The keyword goes through six stages. Each stage is a separate queue with its own worker pool, so a slow platform never blocks a fast one.
An LLM turns the keyword into a tree. The root is the solution. Branches are the shapes the pain takes. Leaves are specific situations a real person would be in.
Each leaf carries more than a label:
pain_phrases, the wording someone in that situation would actually use
negative_terms, wording that means this is not a lead, so it can be dropped before it costs anything
icp_hint, who this person is
a set of search queries written per platform, in that platform's voice
That last point matters. Reddit phrasing is not LinkedIn phrasing. Cross producting one query set across three platforms is how a search budget disappears for nothing, so the model writes each platform's queries separately.
There is a hard cap of 400 queries per run. If the tree overshoots it, the run fails loudly instead of silently truncating, because a tree that big is a prompt regression and quietly dropping half the queries would hide it behind a merely mediocre run.
Every compiled query is dispatched as its own task. Results come from Serper, with SerpApi as the fallback if Serper errors or has no key.
Search depth is pinned at 10 results per query. Serper bills a second credit at 11, so depth 10 is the last free row.
LinkedIn is capped to one query per leaf. That number came from measurement, not taste:
Keyword | Candidates | Leads |
|---|---|---|
autoblogging plugin wordpress | 207 | 1 of 13 |
ai seo friendly content generator | 299 | 11 of 108 |
LinkedIn was consuming 30 to 45 percent of discovery spend for 8 to 10 percent of leads. That is structural rather than unlucky: LinkedIn is where people announce wins, and this pipeline looks for people with problems. It is capped rather than deleted, because two keywords is not enough evidence to remove a platform.
Candidates are enriched with whatever the source will give up.
Reddit is read as JSON behind a cookie jar minted by a headless browser. LinkedIn gets its own dedicated low concurrency queue, a random delay before each fetch so that even low concurrency does not become a tight burst, and a circuit breaker that abandons LinkedIn for the rest of the run after 20 throttle events rather than hammering an IP that has already said no.
This is the cost gate, and it decides the LLM bill for the whole run.
Every candidate and every leaf is embedded into the same 1024 dimensional space, then compared with one cosine matrix. Candidates that are not close to any leaf are dropped before a scoring model ever sees them.
Three design choices here, each with a reason:
Numpy, not pgvector. 20,000 candidates at 1024 dimensions against roughly 300 leaves is a single matrix multiply: 12.29 GFLOP, measured at about 48 milliseconds. The same 6 million pairs computed one at a time takes about 31.6 seconds, which is 387 times slower, because a batched matmul amortizes memory traffic through cache blocking and pairwise distance cannot. pgvector has no batch path, so routing this through the database means paying the network round trip and the slower algorithm. The vectors are already in memory at the moment they are created.
No approximate index. pgvector only consults an HNSW index when the query is an ordered top-k. This is a threshold gate across the entire run with no top-k, so the index would never be used. Approximate recall would also be disqualifying for a recall critical gate on its own.
A percentile, not a fixed cosine. Absolute cosine values shift with model, input type, dimension and dtype, and embedding anisotropy leaves them systematically miscalibrated while rank order stays intact. A hardcoded threshold like 0.72 quietly means something different after any of those change. A percentile does not.
The gate is deliberately tuned toward recall. A false negative is gone forever, since nothing downstream can recover a dropped candidate. A false positive costs one cheap scoring slot. That asymmetry is enormous and the threshold reflects it.
Survivors go to an LLM in batches. Each one comes back with pain_match, icp_match, is_seeking, actionable, a short reason, and a suggested reply angle.
The final score is:
final = pain * icp * recency_decay * (1 + min(log1p(engagement), cap))
with is_seeking and actionable as hard gates rather than factors. A vendor's ad and a dead thread are not weak leads to be ranked low. They are not leads at all, and letting a strong pain match drag them up the list is exactly the failure that makes a results page untrustworthy.
Recency decays exponentially with a 180 day half life. An unknown date scores 0.5 rather than 1.0, because we cannot verify it is fresh.
Engagement is capped, and the reason is worth stating because it was a real contradiction in the system. A crowded thread is handed to the scorer as a reason to mark it not actionable, since there is no room left for a useful reply. Meanwhile an uncapped log1p(engagement) rewards exactly that crowding: 1,000 upvotes multiplies a score by 7.9 while a perfect fit post with 5 upvotes gets 2.8, so popularity outranks fit. The resolution splits the signal at the source, where upvotes are reach and go into engagement while comments are saturation and go into the answer count, then bounds what survives. Engagement can now order two comparable leads without overturning a better match.
The first run with real Reddit engagement behaved as intended. The top lead was a 24 upvote post scoring 0.898 on pain 90 and ICP 70, while the most upvoted post in the entire run, at 325 upvotes, scored 0 because it was a tool recommendation thread and the gates killed it before any multiplier was reached.
Counts are rolled up, including how many candidates were rejected for not seeking and how many for not being actionable, and the run is marked done.
Intent Miner is listed on OKX.AI as ASP #9860 with two services, both priced per call in USDT and settled on X Layer.
Service | Price | Method |
|---|---|---|
Keyword intent mining job | 0.05 USDT |
|
Job status and result links | 0.001 USDT |
|
Payment runs on the x402 protocol through OKX's facilitator, using the exact scheme with EIP-3009 stablecoin transfers, which means no on chain approve step. That is what keeps a 0.001 call from costing more to collect than it earns.
A few implementation decisions that mattered:
The protocol work is not hand rolled. The first version of this file was 229 lines that minted its own challenge and called verify and settle directly. It was written from buyer side documentation and it was wrong in a way that would never have surfaced as an error: OKX's broker authenticates with HMAC-SHA256 request signing, which the buyer docs do not mention. It would have failed closed forever while looking exactly like a configuration problem. The whole thing was replaced with the official SDK, and what remains is only the part that is genuinely ours, deciding what each route costs.
Prices come from one place. The number registered on chain and the number charged at the endpoint are the same config value. Two sources for one price is how a marketplace ends up advertising 0.05 while the endpoint quotes something else, with nothing erroring.
The advertised resource URL is pinned, not derived. Behind nginx the app is reached over plain HTTP. A resource derived from the incoming request would advertise http://host/... while buyers pay at https://host/..., and a buyer who pays for one resource and replays against another is exactly what the resource field exists to prevent.
It fails closed. With no credentials configured, the payment middleware is never installed and every paid route returns 503 naming the missing settings. Not 402, because inviting payment to a service with no configured payee takes money nobody can settle. Not 200 either, since serving the work would make an unconfigured deployment a free one. There is no bypass flag, for the same reason.
Settlement is synchronous. The payment confirms before the resource is returned. Asynchronous settlement would hand over a job whose payment had not landed, and a job is not recoverable once the pipeline has already spent LLM and search credits running it.
Python, FastAPI, dramatiq over Redis, Postgres with pgvector for storage.
Seven containers under Docker Compose:
postgres redis api
worker-expand worker-discover worker-fetch worker-fetch-linkedin worker-score
Workers are split by queue rather than run as one pool, which is what lets LinkedIn sit on low concurrency with jitter while discovery runs wide.
Stage transitions use a Redis backed barrier that advances at 95 percent completion rather than 100. One hung provider must not stall an entire run behind a barrier that never completes.
Model providers are ordered with a fallback rather than hardcoded: OpenAI primary with Anthropic behind it, Voyage embeddings with OpenAI behind them. Both stages of a run lock to the same embedding provider, because leaf vectors and candidate vectors must share a space to be comparable at all. Mixing them would not error, it would just produce meaningless cosines.
Published ports bind to loopback only, with nginx terminating TLS in front.
183 tests, including tests that specifically cover the two ways the paid surface can lose money without erroring: charging an amount that is not the price registered on chain, and serving work when the payment gate was never installed.
Registration and both services were exercised with real money on mainnet, not a testnet and not a mock.
What | Transaction |
|---|---|
Register ASP #9860 |
|
Start a job, 0.05 USDT |
|
Read results, 0.001 USDT |
|
The keyword was local seo. It returned 39 scored links in a few minutes, 31 from Reddit, 7 from Quora, 1 from LinkedIn. Among them:
How can a small local business without a website still rank on Google in 2025
Why does my competitor show up everywhere online while my business is barely visible
My restaurant has only 3.6 stars, how do I hire a service to write reviews so that I can get more customers through the door
How do restaurant owners typically handle responding to negative Google and Yelp reviews, I'm getting more bad reviews and don't know how to respond professionally without making it worse
Every one of those is a person, in public, describing a problem that a local SEO product solves.
MVP
Self-funded