The work is mechanical. The decision is not.
Internal links do two jobs: they tell search engines which pages matter, and they move a reader to the next useful page. Both depend on the same judgment, made once for every pair of pages. On a 600-page site with 15 pages you care about, that is 9,000 decisions. Nobody makes them by hand, so most sites link by habit or by plugin.
This is a textbook case for the machine doing the work while the human evaluates. Jev makes the 9,000 small calls. You review a short CSV of proposals and approve the ones an editor would have made.
New to Jev? The quickstart gets you a key and a working call in about five minutes.
One call per page, not one per pair
The obvious design calls the model once for every page-and-destination pair. It works, and it re-sends the full page body 15 times. The body is almost the entire payload, so you pay for it 15 times.
Jev evaluates every question in a call in parallel against the same state. So make the page the state and each destination a question. The body travels once and you get 15 answers back. Same model, same answers, a fraction of the tokens.
Skip destinations the page already links to, and the page itself, before you build the questions. Fewer questions, cleaner output.
Ask about the reader's next click
The question is your only lever on quality. "Is this page relevant to X?" invites topic matching, and every page about pricing looks relevant to every other page about pricing. What you want is intent:
Someone who has just read this page wants to go further and would click through to "{destination title}" because it answers their next question.Two details matter. Use the destination's human title, not its URL. And keep each question to one claim, which is also what TypeSafe's docs recommend: atomic questions, no chains of reasoning.
Demand a clear winner or add nothing
A single cut-off such as "keep anything above 0.75" fails in a specific way. On a page with one obviously right destination, that destination can win by a wide margin and still sit below the bar. Ben Broch documented this on real pages: his flat threshold produced no links at all.
So rank the destinations for each page and propose the top one only when both hold:
- Floor: it clears about 0.6 on its own, so it is a plausible yes.
- Margin: it beats the runner-up by about 0.15, so it is not a coin toss between two pages.
When the top two are close, the page gets no link. That is the correct outcome. Loosening the rule to force more links is what makes an automated link map read as automated. Treat 0.6 and 0.15 as starting points and calibrate them on 30 to 50 pages you have labelled yourself, the same way you would build any golden eval set.
Run it yourself
About 90 lines of Node. Point it at your sitemap, list your destinations, and it writes a CSV of proposals with the winner, the runner-up, and the margin, so you can see why each row was kept or dropped. It runs 20 pages by default. Read that CSV before you raise the limit.
// link-map.mjs | npm install ai | node --env-file=.env link-map.mjs
import { experimental_evaluate as evaluate } from 'ai';
import { writeFileSync } from 'node:fs';
const SITEMAP = 'https://example.com/sitemap.xml';
const DESTINATIONS = [
// The 10-20 pages you want links pointing at. Short, human titles.
{ url: 'https://example.com/guides/pricing-strategy/', title: 'How to price a B2B service' },
{ url: 'https://example.com/services/audit/', title: 'Pipeline audit service' },
];
const LIMIT = 20; // pages per run; raise it after you have read the first CSV
const FLOOR = 0.6; // the winner must be a plausible yes on its own...
const MARGIN = 0.15; // ...and clearly beat the runner-up
const CAP_PER_DEST = 25; // stop one money page soaking up every link
const MAX_CHARS = 24000; // keeps the state well inside Jev's 32K-token window
const text = (html) => html
.replace(/<(script|style|nav|header|footer|aside)[\s\S]*?<\/\1>/gi, ' ')
.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
async function getPage(url) {
const html = await (await fetch(url)).text();
return {
url,
title: html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim() ?? url,
html,
body: text(html).slice(0, MAX_CHARS),
};
}
// Run a few calls at a time. Bursts of hundreds get rate-limited.
async function pool(items, size, fn) {
const out = []; let i = 0;
await Promise.all(Array.from({ length: size }, async () => {
while (i < items.length) { const n = i++; out[n] = await fn(items[n]); }
}));
return out;
}
const xml = await (await fetch(SITEMAP)).text();
const urls = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]).slice(0, LIMIT);
const rows = await pool(urls, 3, async (url) => {
const page = await getPage(url);
// Skip itself and any destination this page already links to.
const candidates = DESTINATIONS.filter((d) =>
d.url !== url && !page.html.includes('href="' + d.url) && !page.html.includes('href="' + new URL(d.url).pathname));
if (candidates.length === 0) return { url, status: 'nothing to add' };
// ONE call per page: the body travels once, every destination is a question.
const { answers } = await evaluate({
model: 'typesafe-ai/jev',
state: 'PAGE TITLE: ' + page.title + '\n\n' + page.body,
questions: Object.fromEntries(candidates.map((d, k) => ['d' + k, {
type: 'boolean',
instructions: 'Someone who has just read this page wants to go further and would click through to "' +
d.title + '" because it answers their next question.',
}])),
maxRetries: 2,
});
const ranked = candidates
.map((d, k) => ({ to: d.url, p: answers['d' + k].probability }))
.sort((a, b) => b.p - a.p);
const [top, next = { p: 0 }] = ranked;
const margin = top.p - next.p;
const keep = top.p >= FLOOR && margin >= MARGIN;
return { url, to: top.to, p: top.p, runnerUp: next.p, margin, status: keep ? 'propose' : 'too close' };
});
// Enforce the per-destination cap, strongest proposals first.
const used = {};
for (const r of rows.filter((r) => r.status === 'propose').sort((a, b) => b.p - a.p)) {
used[r.to] = (used[r.to] ?? 0) + 1;
if (used[r.to] > CAP_PER_DEST) r.status = 'over cap';
}
const f = (n) => (n === undefined ? '' : n.toFixed(3));
writeFileSync('link-map.csv', ['from,to,probability,runner_up,margin,status',
...rows.map((r) => [r.url, r.to ?? '', f(r.p), f(r.runnerUp), f(r.margin), r.status].join(','))].join('\n'));
console.log(rows.filter((r) => r.status === 'propose').length, 'proposed links of', rows.length, 'pages -> link-map.csv');Then choose the anchor text
For each approved row, pull a shortlist of phrases that already appear on the source page and ask which one the link belongs on. Constraining the choice to real on-page text is what makes a link read as if you wrote it. Never let the model invent anchors.
The arithmetic, shown
Jev bills input tokens only, at $0.042 per million at launch. For a 600-page site, 15 destinations, and pages of about 6,000 tokens:
| Design | Tokens | Cost |
|---|---|---|
| One call per page | 600 × (6,000 body + ~600 questions) ≈ 3.96M | ≈ $0.17 |
| One call per pair | 9,000 × (6,000 body + ~40 question) ≈ 54.4M | ≈ $2.28 |
Both numbers are small in absolute terms. The ratio is the point: the architecture decision is worth more than most model-choice decisions, and it compounds when you rerun the job every time you publish. The same reasoning drives LLM inference cost generally: the payload you repeat is the payload you pay for.
Or paste this into Claude Code or Codex
It carries the call shape, the wording, the selection rule, the cap, and the guardrail that keeps it off your live site.
Build an internal-linking proposal for my site using Jev through Vercel AI Gateway. Proposal only: nothing changes on my site.
1. Ask me for my sitemap URL and the 10-20 destination pages I want links pointing at, each with a short human title. Do not start until I answer.
2. Use experimental_evaluate from the ai package with model 'typesafe-ai/jev'. Read the AI SDK reference first.
3. For each source page: strip nav, header, footer and scripts, trim the body to about 24,000 characters (Jev's window is 32K tokens), and skip destinations the page already links to or is itself.
4. Make ONE call per source page. The page is the state; each remaining destination is one boolean question. Never make one call per page-destination pair: it re-sends the page body for every destination and multiplies the cost for the same answers.
5. Word each question around the reader's next step, not topic similarity: 'Someone who has just read this page wants to go further and would click through to "<title>" because it answers their next question.'
6. No flat threshold. Rank destinations per page and propose the top one only if it scores at least 0.6 AND beats the runner-up by at least 0.15. Otherwise mark the page "too close" and propose nothing.
7. Cap proposals at 25 per destination, strongest first.
8. Run 3 calls at a time. Process the first 20 pages only, write link-map.csv (from, to, probability, runner_up, margin, status), and stop to show me.
9. After I approve rows, suggest anchor text for each by choosing from phrases that ALREADY appear on the source page. Never invent anchor text.
10. Never edit, publish, or insert links. I review the CSV and make the changes myself.Where this goes wrong
- Near-duplicate pages score the same. If several pages return identical probabilities, they are probably saying the same thing. Consolidate them; no linking rule fixes cannibalisation.
- Trimming loses context. Very long pages lose their second half at 24,000 characters. For long guides, send the introduction and the section headings instead.
- One link per page is conservative. The script proposes only the clearest winner. That is deliberate. Add a second pass later if the first earns your trust.
- The review is not optional. The model proposes. A person approves. A human glance is not a plan, so review against the margin column, not by vibes.
Internal linking with Jev: common questions
Can Jev automate internal linking?
It can automate the judgment: which source pages should link to which destination pages. Treat the output as a proposal. A person should approve the link map and the anchor text before anything changes on the live site.
How much does automated internal linking with Jev cost?
At $0.042 per million input tokens, a 600-page site with roughly 6,000-token pages and 15 destinations costs about 17 cents when each page is sent once with all destinations as questions. Sending one call per page-and-destination pair costs about $2.28 for the same answers.
What probability threshold should I use for internal links?
Avoid one flat threshold. Rank the destinations for each page, keep the top one only if it clears a floor and beats the runner-up by a clear margin, and calibrate both numbers on 30 to 50 pages you have labelled by hand.
How should I choose the anchor text?
Pick it from phrases that already appear on the source page. Never let a model invent anchor text; invented anchors are the fastest way for a link map to read as automated.
How many destination pages should I use?
Start with 10 to 20 pages that earn something: pillar guides, product pages, and the posts you most want to rank. A short list keeps the run cheap and the link map editorial.
Sources and further reading
- Jev on Vercel AI Gateway: pricing and the 32K context window.
- TypeSafe docs: parallel questions against one state; atomic question design.
- AI SDK reference: experimental_evaluate.
- Ben Broch: How to automate SEO internal linking with Jev, including his tests of question wording and thresholds.
- anchorlint: open-source internal-link auditing with optional Jev judgments.