ANAlpesh Nakrani
SolutionsBlogBooksPraiseAboutWork with me
Solutions/SEO internal linking

Jev field guide · SEO

Internal linking is judgment at scale. Price it like it.

Deciding which page should link to which is thousands of small yes-or-no calls. Here is how I would hand them to Jev: one call per page, a selection rule that knows when to say nothing, a script, and an agent prompt that stops before it touches your site.

By Alpesh Nakrani · Updated September 27, 2026

600source pages
9,000link decisions
~$0.17one call per page
~$2.28one call per pair

Worked example, not a benchmark: 15 destinations, ~6,000-token pages, $0.042 per 1M input tokens. Arithmetic in section 06.

TL;DR

  • Send each page once and ask about every destination in the same call. That one choice cuts the bill about 14 times.
  • Ask whether a reader would click through next, not whether two pages are "related".
  • Rank destinations per page and require a clear winner. No clear winner means no link.
  • Pick anchors only from text already on the page, cap links per destination, and approve every row yourself.
01/Why

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.

02/Call shape

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.

03/Wording

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:

Question template
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.

04/Selection rule

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.

05/Script

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

06/Cost

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:

DesignTokensCost
One call per page600 × (6,000 body + ~600 questions) ≈ 3.96M≈ $0.17
One call per pair9,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.

07/Agent build

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.

Prompt for Claude Code / Codex
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.
08/Trade-offs

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.
09/FAQ

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

Sources and further reading

Ask AI about automating SEO internal linking with the Jev evaluation model