586 pages. 8,790 judgments.
21 cents.
Internal linking is mechanical work that people pay frontier-model prices for. Here’s the whole algorithm, the one structural choice that does most of the saving, and the threshold rule I got wrong the first time.
A quick mental model
Jev takes a situation and answers small typed questions about it. The trick here is what you put in each slot: the state is one source page, and the questions are every destination at once.
Every question is judged in parallel against that same state in a single call, and adding questions barely changes the response time. So the page body — the expensive part — travels once and you get fifteen answers back.
New to Jev? Start with the setup guide — key, first call, working response. Then come back.
Crawl, then pick your destinations
Pull every URL you own with its title and body text. Then choose the handful of pages you actually want links pointing at — the ones that earn something. In the run this is built from that was 586 source pages and 15 destinations, which is 8,790 pairs to judge.
Keeping the destination list short is what makes it cheap, and what keeps the link map looking editorial instead of automated.
One call per page — not one per pair
This is the entire trick. The obvious shape is one call per (page, destination) pair, and it re-sends the same page body for every destination.
Ask about the click, not about relevance
The judgment is only as good as the sentence you hand it, and this is not a small effect. Three wordings, the same 13 pages, the same model:
- “Would a link to X help, rather than merely share a topic?” — zero usable links, and it put a random country page above the obvious explainer.
- “This page leaves something unanswered that X answers.” — five links, ranked wrong. Confident and incorrect, which is the dangerous one.
- “A reader who finishes this page still needs X. Would they click it and find what they came for?” — ranked correctly, with clean separation.
Asking whether something is “relevant” gets you topic-matching. Asking what the reader does next gets you a link.
Judge the gap, not the number
I had planned to tell you to keep anything above 0.75. On real pages that produces zero links — not because the model is wrong, but because it is right at the wrong altitude:
0.700 /what-is-idp <- correct, and decisive
0.410 /idp-requirements/japan
0.390 /idp-requirements/italy
0.320 /idp-requirements/mexico
0.280 /lost-idp-replacementThe right answer won by a mile and still sat under the bar. So rank them, and keep the winner only if it beats the runner-up by a clear margin — 0.15 worked — and clears 0.60 on its own.
Cap it, then choose the anchor text
Cap links per source page and per destination, or one money page quietly absorbs every link on the site. Then run a second, cheaper pass over the survivors: hand it a shortlist of phrases that already appear on the source page and ask which one the link belongs on.
Constraining it to real on-page text is the difference between a link that reads like you wrote it and one that reads like software wrote it. Never let it invent the anchor.
Let an agent build it
Paste this at Claude Code or Codex. It carries the one decision that matters, the wording that worked, the gap rule, and the guardrails that keep it off your live site.
Build me an internal linking job for my site using Jev through the Vercel AI Gateway.
1. Crawl my sitemap and pull each page's title and body text. Trim each body to roughly 24k characters; Jev's state budget is 32k tokens.
2. I will give you the handful of destination pages I want links pointing at. Ask me for them before you start.
3. For every source page make ONE call: experimental_evaluate from the ai package, model typesafe-ai/jev, the page as the state, and one boolean question per destination. Never one call per pair — that re-sends the whole page body for every destination and costs about 15x for an identical answer.
4. Word each question about the reader's next move, not about relevance: "A reader who finishes this page still needs X. Would they click it and find what they came for?" Asking whether a link would be "helpful" or "relevant" reads as topic-matching and ranks badly.
5. Do not use a flat probability threshold. Rank the destinations per page, then keep the winner only if it beats the runner-up by at least 0.15 and clears 0.60 on its own. If the top two are a hundredth apart that is noise, and the page gets no link.
6. Write a CSV of from, to, probability. Do the first 20 pages only, then stop and show me the CSV.
7. Do not edit my site, do not publish anything, and do not insert a single link until I have read it and said go.Or run it yourself
About fifty lines. Point it at a sitemap, list your destinations, get a CSV.
// npm install ai
// .env: AI_GATEWAY_API_KEY=...
// run: node --env-file=.env internal-links.mjs
import { experimental_evaluate as evaluate } from 'ai';
import { writeFileSync } from 'node:fs';
const SITEMAP = 'https://example.com/sitemap.xml';
const DESTINATIONS = [
{ url: 'https://example.com/pricing', title: 'Pricing' },
// ...the handful of pages you actually want links pointing at
];
const FLOOR = 0.60; // has to be a plausible yes
const MIN_GAP = 0.15; // and it has to clearly beat whatever came second
const strip = (html) => html
.replace(/<(script|style|nav|footer)[\s\S]*?<\/\1>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
async function pages(limit = 20) {
const xml = await (await fetch(SITEMAP)).text();
const urls = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
const out = [];
for (const url of urls.slice(0, limit)) {
const html = await (await fetch(url)).text();
const title = html.match(/<title>([^<]*)<\/title>/i)?.[1] ?? url;
out.push({ url, title, body: strip(html).slice(0, 24000) });
}
return out;
}
const destUrls = new Set(DESTINATIONS.map((d) => d.url));
const rows = [];
for (const page of await pages()) {
if (destUrls.has(page.url)) continue; // never link a page to itself
const { answers } = await evaluate({ // ONE call, every destination at once
model: 'typesafe-ai/jev',
state: 'TITLE: ' + page.title + ' -- ' + page.body,
questions: Object.fromEntries(DESTINATIONS.map((d) => [d.url, {
type: 'boolean',
instructions: 'A reader who finishes this page still needs "' + d.title +
'". Would they click it and find what they came for?',
}])),
maxRetries: 0,
});
const ranked = Object.entries(answers)
.map(([url, a]) => ({ url, p: a.probability }))
.sort((a, b) => b.p - a.p);
const [top, next] = ranked;
if (top && top.p >= FLOOR && (!next || top.p - next.p >= MIN_GAP)) {
rows.push(page.url + ',' + top.url + ',' + top.p.toFixed(3));
}
}
writeFileSync('link-map.csv', ['from,to,probability', ...rows].join('\n'));
console.log(rows.length, 'links -> link-map.csv');Why it costs 21 cents
Jev bills $0.042 per million input tokens and nothing for output. Work backwards from the demo’s own total and the architecture falls out:
$0.2111 / $0.042 per M = 5.03M tokens
5.03M / 586 calls = 8,577 tokens per call8,577 tokens is one page body plus fifteen short questions — which means the run really was one call per page. Done the other way each body ships fifteen times: 75.4M tokens, $3.17. Same model, same output, 15× the bill.
Figures from a public demo by @borjafat, 18 September 2026.
The honest part
Steps 3 and 4 exist because I ran this before I wrote it. Thirteen real blog posts on a site I own, about a tenth of a cent. The flat threshold I had drafted produced nothing at all, and the wording I had drafted ranked a random country page above the obvious explainer. Both are fixed above.
That run also turned up something unrelated: four of the thirteen posts scored identically to three decimal places, because they are near-duplicates of one another. No linking strategy fixes that. If your scores come back suspiciously uniform, check whether the pages are actually different.
The frontier-model comparison in the demo is an extrapolation it prints itself, from a partial run — it stopped at 21 of 586 pages. And “190× cheaper” is the per-page figure; the overall-cost framing is closer to 204×. Both are defensible, they just use different denominators. Don’t mix them in one sentence.
Don’t have Jev running yet?
The setup guide is one key and a few lines of code — a working response in a few minutes.
Get started with Jev →