shadimage
agentblog
blockagent-kitsource: ok · hit

AgentBlog agent kit

Seven skills that take a blog from install to published: finish the setup for your site, research what to write with real search and AI citation data, plan the topic clusters, write posts to the GEO playbook, refresh them without falsifying dateModified, audit before publish, and publish with the IndexNow response code actually read. Installs into your project's .claude/skills directory, so any agent working in this repository picks them up. Also available as a Claude Code plugin and through npx skills add.

npx shadcn add "https://agentblog.dev/r/agent-kit.json"
free

Where it goes

blog-list

Visual style

terminal-mono

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files16 · source cached 10/12/2026, 10:41:55 PM

registry/agent/skills/agentblog-setup/SKILL.md
---
name: agentblog-setup
description: >
  Finishes an AgentBlog install end to end: repairs the config wiring the registry
  alone cannot do, then replaces the seed identity, authors, categories, and posts
  with the user's own so the blog is theirs rather than AgentBlog's. Verifies with
  agentblog doctor rather than asserting success. Use after installing AgentBlog
  with shadcn add or agentblog init, when asked to finish setting up the blog or
  configure it for a site, when agentblog doctor reports findings, or when the blog
  still shows placeholder branding, example.com links, or the AgentBlog seed posts.
allowed-tools: Read Write Edit Glob Grep Bash(npx agentblog *) Bash(git check-ignore *)
license: MIT
compatibility: A Next.js project with AgentBlog installed. Needs network access for the live crawler check.
metadata:
  package: agentblog
  homepage: https://agentblog.dev
---

# Finish the install, then make the blog theirs

AgentBlog installs in two layers and finishes in three. `shadcn add` copies the
files. The `agentblog` CLI patches the two files that live outside the block.
Neither can do the third thing, which is turn a correct install into this user's
blog: the seed content ships AgentBlog's own author, AgentBlog's own four
categories, and two posts about AI search.

A user who stops after layer two has a blog that renders, passes `doctor`, and
publishes somebody else's topics under a placeholder byline.

Work in that order. Phase 1 is mechanical and mostly automated. Phase 2 needs the
user, and it is the phase that decides whether the blog is worth crawling.

Assume the shadcn CLI skill covers `shadcn add`, registries, and `components.json`.
Assume `node_modules/next/dist/docs/` covers Next.js API shapes for the exact
version installed. This skill covers the wiring between them.

**Never use an em dash** in anything you write into this repository, including
config comments, author bios, and category descriptions.

```
- [ ] 1. Read the state before changing anything
- [ ] 2. Let doctor --fix do the mechanical repairs
- [ ] 3. Fill agentblog.config.ts with real values
- [ ] 4. Write the real author record
- [ ] 5. Replace the taxonomy with the user's topics
- [ ] 6. Decide what happens to the seed posts
- [ ] 7. Verify, do not assert
```

## Phase 1: converge the install

### 1. Read the state before changing anything

```bash
npx agentblog doctor --verbose
```

It enumerates every check and names the ones that fail, which is a better starting
point than inspecting files by hand. Read its output before opening anything.

Then read `components.json`. **If it is absent, stop.** AgentBlog requires an
initialised shadcn project, and creating `components.json` on the user's behalf
makes decisions about style, base colour, and aliases that are theirs. Tell the user
to run `npx shadcn@latest init` and stop.

Note whether the project uses a `src/` directory, because every path below shifts.

### 2. Let `doctor --fix` do the mechanical repairs

```bash
npx agentblog doctor --fix --dry-run   # show the user the diff first
npx agentblog doctor --fix
```

It repairs exactly this, and backs every file up to `.agentblog/backup/` first:
the `htmlLimitedBots` union and `images.qualities` in `next.config.ts`;
`metadataBase`, `title.template`, and the RSS alternates link in the root layout;
an environment guard in `app/robots.ts`; the AgentBlog block in `AGENTS.md`, placed
after the region Next.js manages; `INDEXNOW_KEY` and `AGENTBLOG_REVALIDATE_SECRET`
in `.env.local`, moved out of any git-tracked env file; and the IndexNow key file
in `public/`. Anything it declines to change is printed with the reason.
`npx agentblog revert` puts it all back.

Prefer it over hand-editing every time. Three things are worth knowing anyway,
because they decide whether you accept its output or investigate.

**`htmlLimitedBots` replaces the Next.js default bot list rather than extending
it.** The value must be a union: every AI crawler _plus_ the entire Next.js default
list. A patch that writes only the AI crawlers drops Googlebot, Bingbot, Applebot,
Twitterbot, LinkedInBot, Slackbot, Discordbot, and facebookexternalhit from
HTML-limited treatment, which trades a live SEO and social-preview regression for a
GEO gain. `doctor --fix` performs the union correctly and widens rather than
replaces, preserving entries the user added. If you ever edit it by hand: the type
is `RegExp` and a string fails config validation at startup, it is a top-level key
rather than an `experimental` one, and `/.*/` is never the answer because it
disables streaming metadata for every human visitor.

**`doctor --fix` leaves `htmlLimitedBots` byte for byte alone when it cannot read
the current value as a literal.** That is deliberate, and it means a computed value
needs your eyes. Widen it by hand, never replace it.

**Never write `CLAUDE.md`.** Next.js generates it containing `@AGENTS.md`, which
already imports everything in `AGENTS.md`. Writing there duplicates the block into
context twice, and `next dev` may rewrite the file underneath you. If
`agentRules: false` is set in `next.config.ts`, Next.js writes neither file, so our
block is the only agent instruction in the project. Say so to the user rather than
assuming Next.js has their back.

### 3. Fill `agentblog.config.ts` with real values

The template ships with placeholders, and `doctor` reports them rather than
inventing replacements. The config must call `defineConfig(...)`, not export a bare
object and not use `satisfies`, because `defineConfig` enforces the cross-field
requirements the type alone cannot express.

Ask the user for anything you cannot determine from the repository. Do not guess a
site URL from `package.json` and do not invent a brand name.

| Field           | Notes                                                                                                                                                          |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `siteUrl`       | Absolute https URL, bare origin, no trailing slash, no query, no fragment. Anchors every canonical, every `@id`, and the sitemap.                              |
| `brand.name`    | The publisher name. Appears in `Organization`, `WebSite`, and the title template.                                                                              |
| `brand.logo`    | `url`, `width`, and `height`. All three. A logo without dimensions is an incomplete `ImageObject`.                                                             |
| `brand.sameAs`  | Absolute profile URLs, never handles. LinkedIn, Crunchbase, YouTube, Wikidata where they genuinely exist. Leave the array empty rather than inventing entries. |
| `source`        | The content adapter. `mdxSource({ dir: 'content/blog' })` for the default filesystem install.                                                                  |
| `deployHook`    | Required when the adapter's `prerenderStrategy` is `'deploy-hook'`. Without it, publishing without a rebuild silently serves stale content.                    |
| `defaultAuthor` | A slug that exists in `content/authors.json`. Set it in the same change as step 4.                                                                             |

Everything else is defaulted. Do not restate a default in the config file just to
make it visible. That is how a default silently becomes a pin.

## Phase 2: make the blog theirs

Phase 1 produced a correct install. Everything in it would pass `doctor` while the
site published a placeholder byline linking to `example.com`. This phase is the one
nothing can automate, and it is where the entity signals come from.

Interview the user. Ask for what you cannot read out of the repository, in one
round rather than one question at a time: who writes here, what they are credible
on, which profile URLs are real, and what the blog is about.

### 4. Write the real author record

`content/authors.json` ships one record with slug `editorial` and placeholder
values. `agentblog.config.ts` points `defaultAuthor` at it and both seed posts name
it, so **keep the slug or change it in all three places in one commit**. `doctor`
reports `seed-authors-unedited` until the values change.

This record is a ranking surface rather than metadata. The Search Quality Rater
Guidelines direct raters to look authors up, and an engine resolving "who wrote
this" reads these fields together.

- `name`: the person's name and nothing else. No job title, no honorific, no
  publisher name. Google's Article guidance is explicit, and `jobTitle` exists so
  the correct shape is the only constructible one.
- `bio`: two or three sentences on why this person is credible on these topics.
  Specific experience, not adjectives.
- `jobTitle`, `worksFor`, `alumniOf`: fill what is true, omit what is not.
- `knowsAbout`: the topics this author is genuinely credible on. These become
  `Person.knowsAbout` and should overlap the taxonomy in step 5.
- `sameAs`: absolute profile URLs. This is the single strongest entity signal in
  the whole graph, and a handle disambiguates nothing. **Ask the user for these.
  Never invent one, and never leave an `example.com` placeholder in place.** An
  empty array is better than a wrong URL.

Delete the `guest-author` record unless the user wants it. It exists only to show
the shape of a roster with more than one person in it, and no seed post names it.

### 5. Replace the taxonomy with the user's topics

`content/categories.json` ships four categories that are AgentBlog's own subject
matter. Two of them are load-bearing while the seed posts are present: `ai-search`
and `content-strategy` are named in seed post frontmatter, and removing either
before the post that references it fails the next build with `unknown category
slug`. Do step 6 first, or do both in one commit.

Aim for five to ten fixed categories. Each one is an indexable hub page, and a hub
with two posts on it is a crawl liability rather than an asset. Fewer real
categories beats more aspirational ones.

Every category needs a real `description`, because the hub page is indexable and
the schema rejects an empty one. Two sentences saying what belongs in the category
and what does not.

### 6. Decide what happens to the seed posts

`content/blog/` holds two posts about how AI search engines read a blog. They are
good posts and they are not this user's posts. Put the decision to the user with a
recommendation rather than deleting content on your own initiative:

- **Delete them** if the blog is about something else, which is the usual case.
  Then remove the categories they pinned, in the same commit.
- **Keep them** if the blog is about search, AI, or web performance, and rewrite
  the byline and internal links to match the new identity.
- **Keep them unpublished** by setting `draft: true`, if the user wants the format
  reference without the URLs. They stay out of every query, the sitemap, and the
  feed.

Whichever way it goes, the seed posts are the format specification for everything
`write-blog-post` produces afterwards. If they are deleted, say so, because the
next post has no house example to read.

## 7. Verify, do not assert

```bash
npx agentblog doctor --verbose
```

Report its output. If findings remain, fix them or explain why each one is
intentional.

Then, if the site is deployed, run the live check. It is the only one that tests
what a crawler actually receives:

```bash
npx agentblog doctor --url https://example.com
```

That probes the site as GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, and
Googlebot, and detects a CDN sitting in front. **Run it from your own machine or
from CI, never from inside the deployment**, because a request originating inside
the network can bypass the CDN rule the check exists to find.

A 403, a 429, or a challenge page there means a CDN is blocking AI crawlers
regardless of what `robots.txt` says. That is the single most common cause of a
correctly installed blog getting no citations, and it is a blocking failure. Name
the CDN in the report.

`Googlebot` is in that list for a reason worth passing on: Cloudflare classifies
multi-purpose crawlers by their broadest use, and Googlebot crawls for both Search
and AI training with one bot. Ticking a setting that reads as "do not train on my
content" can therefore remove the site from Google entirely.

**Do not report setup as complete while `doctor` reports an error, or while any
author, category, or config field still holds a placeholder.** List what failed and
what it would take to fix.

When it is genuinely done, tell the user the next step is `plan-blog-content` if
they have no editorial plan, and `write-blog-post` if they do.
registry/agent/skills/dataforseo-research/SKILL.md
---
name: dataforseo-research
description: >
  Runs keyword, competitor, SERP, and AI citation research through DataForSEO, and
  keeps it. Reads the research catalog in content/research/ before spending
  anything: with no catalog it establishes a baseline of what the site ranks for,
  who owns the answers, and which queries are worth a post; with a catalog it
  re-asks the same questions, diffs against the stored snapshots, and reports what
  moved. Produces clusters built from SERP overlap, striking-distance wins,
  competitor page gaps, and a citation baseline, written to disk so the next run
  compares rather than repeats. Use when asked to research keywords or topics,
  find what to write about next, size an opportunity, check what a site ranks for,
  measure AI, LLM, or AI Overview visibility, analyse competitors, or put real
  numbers under a content plan.
argument-hint: '[topic, domain, or question]'
allowed-tools: Read Write Edit Glob Grep WebFetch Bash(curl *) Bash(npx agentblog *) Bash(git check-ignore *) Bash(claude mcp list)
license: MIT
compatibility: A Next.js project with AgentBlog installed, plus a DataForSEO account. Works over the DataForSEO MCP server or plain REST.
metadata:
  package: agentblog
  homepage: https://agentblog.dev
---

# Research the blog, and keep the research

Every other AgentBlog skill decides something. This one is the evidence those
decisions stand on, and it is the only skill that leaves durable state behind.

That state is the point. A research run that answers a question and evaporates is
a run the user pays for again next month, against a different sample, with no way
to tell a real change from a different question. A run that writes itself down
turns the second run into a diff, which is cheaper, faster, and the only thing
that can say whether the writing is working.

So the first move is never a query. **It is to look at `content/research/` and
find out what this project already knows.** That costs nothing, it decides
everything after it, and skipping it is how an agent bills a user for the answer
they already have on disk.

## The money rule

**Never make a billable call before printing an estimate and getting the user to
approve it.** DataForSEO is prepaid and real. A loop that expands a seed at depth
4 and then reads a SERP per row can spend a hundred dollars in one turn while
looking exactly like a run that spent forty cents.

Three things are free and none of them need approval: reading `content/research/`,
`GET /v3/appendix/user_data`, and any call sent to the sandbox host. Do all of
them first. Everything else waits for a yes.

```
- [ ] 1. Read the catalog before touching the API
- [ ] 2. Reach a working call
- [ ] 3. Decide which run this is, price it, get a yes
- [ ] 4. Baseline run, or
- [ ] 5. Delta run, or targeted run
- [ ] 6. Turn rows into decisions
- [ ] 7. Write the catalog back
- [ ] 8. Report, and hand off
```

Reference material, all one level down and all optional until you need it:

- **Access, credentials, MCP against REST, failure codes**: see
  [references/setup.md](references/setup.md).
- **Which endpoint answers which question, and what it costs**: see
  [references/endpoints.md](references/endpoints.md).
- **How to read the rows as editorial decisions**: see
  [references/playbook.md](references/playbook.md).
- **The on-disk catalog format and the comparison protocol**: see
  [references/catalog.md](references/catalog.md).

### 1. Read the catalog before touching the API

Glob `content/research/`. Then read, in this order, whatever exists:

- `content/research/ledger.md`, the append-only index of every run.
- `content/research/baseline.md`, the current standing answers.
- The filenames in `content/research/snapshots/`, which tell you what was
  measured and when without reading any of them.

Read the newest snapshot only for the question you are about to ask. The others
stay on disk costing nothing.

Also read what the research is for, because research nobody can act on is not
worth buying: `agentblog.config.ts` for the site URL and brand, `content/authors.json`
for what this roster can credibly write, `content/categories.json` for the current
taxonomy, `content/editorial-plan.md` if `plan-blog-content` has run, and the
posts in `content/blog/`.

Then say out loud which of the three states you are in, because the rest of the
run differs:

| On disk                                          | State                                 | Go to                 |
| ------------------------------------------------ | ------------------------------------- | --------------------- |
| No `content/research/`                           | First contact. Nothing is known       | Step 4                |
| A ledger with runs in it                         | There is a history to compare against | Step 5                |
| A ledger, and the user asked one narrow question | Targeted                              | Step 5, narrow branch |

### 2. Reach a working call

Climb this ladder and stop at the first rung that works. Do not install anything
without asking.

1. **MCP tools already in the session.** List them and match on the distinctive
   part of the endpoint path (`ranked_keywords`, `keyword_ideas`, `search_mentions`).
   Never hardcode an MCP tool name: they are not stable, and several currently
   point at paths DataForSEO has renamed.
2. **`DATAFORSEO_USERNAME` and `DATAFORSEO_PASSWORD` in the environment**, or in
   `.env.local`. Use REST.
3. **Neither.** Stop. Give the user the registration and credentials steps from
   [references/setup.md](references/setup.md) and offer to configure the MCP
   server, which needs a session restart and is therefore their call.

Then prove the credentials work before believing anything:

```bash
set -a; [ -f .env.local ] && . ./.env.local; set +a
curl -s -u "$DATAFORSEO_USERNAME:$DATAFORSEO_PASSWORD" \
  "https://api.dataforseo.com/v3/appendix/user_data"
```

Free, and it returns three things the run needs: `money.balance`, so you know
whether the plan can finish; the account's real `price` map, which differs by
plan and beats every published figure; and whether an LLM Mentions subscription
exists at all, which decides whether the AI citation half of this skill is
available.

**A connected MCP server is not an authenticated one.** It builds a valid header
out of two empty strings and reports itself connected, then returns 401 on every
call. `references/setup.md` section 3 has the reason and the fix.

### 3. Decide which run this is, price it, get a yes

Write the plan as a table before spending: each call, why it is in the run, the
row limit, and the cost from the account's own price map. Total it. Show the
balance next to the total.

Then cut it. A first baseline for one blog is well under a dollar when the AI
citation section is aggregates only, and several dollars the moment
`search_mentions` runs at full limit. Most of the value is in the cheap half.

Two habits that keep the estimate honest:

- **Batch everything that batches.** `bulk_keyword_difficulty` and `search_intent`
  take 1,000 keywords per call. `keyword_overview` takes 700, not 1,000. Sending
  200 keywords one at a time costs 200 times what one call costs and returns the
  same rows.
- **Tag every call with the run date**, so the response can be traced back to the
  question later. `tag` is echoed back and costs nothing.

If the balance cannot cover the plan, say so and propose the shorter plan
explicitly. Do not quietly run a smaller version and present it as the whole
thing: a report missing its AI citation section reads exactly like a site with no
AI citations.

### 4. Baseline run

The order matters, because each step narrows the next one and the cheapest
findings come first.

**4a. Harvest before you hunt.** `ranked_keywords` on the user's own domain, then
`domain_rank_overview` for the distribution. This is routinely the most surprising
output of a first run, because a blog usually ranks for queries nobody targeted.
Positions 4 to 20 on a query the site already half-answers beat a new post on a
query it does not answer at all, and they cost a refresh rather than an article.

**4b. Find out who the competitors actually are.** `competitors_domain` on the
domain. The sites competing for the queries are usually not the businesses
competing for the customers, and assuming otherwise is how a research run studies
the wrong sites carefully. Take the top three by keyword overlap, then `relevant_pages`
on each to see which of their posts carry the traffic. Those pages are the real
brief.

**4c. Expand, once, deliberately.** `keyword_ideas` on two or three seeds per
subject when you do not yet have the vocabulary, `keyword_suggestions` on the head
terms when you do, `related_keywords` at **depth 2** when you want phrasing you
would not have guessed. Depth 3 and 4 return 584 and 4,680 rows and are how a
research run becomes a spreadsheet nobody opens.

**4d. Qualify in bulk.** Push every candidate through
`bulk_keyword_difficulty` and `search_intent` in batched calls. Cheapest
instruments in the API per keyword. Drop everything the site cannot plausibly
rank for and everything whose intent wants a page this blog is not.

**4e. Read the SERPs of the finalists.** Ten to twenty queries, one
`serp/google/organic/live/advanced` each, with **`load_async_ai_overview: true`**
and `people_also_ask_click_depth: 2`.

This one call does four jobs: it shows whether an AI Overview fires, it shows who
holds the page, it gives you the People Also Ask phrasing that becomes question
headings, and its top-10 URL sets are what you cluster on in step 6.

`load_async_ai_overview` defaults to `false`, and at the default you get AI
Overview items from cache only. A run that leaves it alone reports that AI
Overviews are rare for these queries, which is a statement about DataForSEO's
cache and not about Google.

**4f. Take an AI citation baseline, aggregates only.** `top_mentioned_domains` for
the subject, and `target_metrics` for the site. Skip `search_mentions` on a first
run unless the user asks for the underlying prompts and accepts the price.

Record the exact prompt set, targets, platform, and date. Everything this section
is worth later depends on the next run asking the identical question.

### 5. Delta run, or targeted run

**The cheapest useful run is a diff, not a re-collection.** Something is already
on disk. Re-ask only what can have changed, and read the rest.

Re-measure: `ranked_keywords` on the domain, and the AI citation aggregates
against the same targets, platform, and prompt set as last time. Both move.

Do not re-measure: keyword expansion, difficulty, and intent. Those move slowly,
they are on disk, and re-buying them produces a diff full of rounding.

Then compare against the stored snapshot and report the four movements that
matter. Each one hands off somewhere different, which is why they are worth
separating rather than reporting as churn:

| Movement                                         | What it means                    | Hand to                             |
| ------------------------------------------------ | -------------------------------- | ----------------------------------- |
| A post fell several positions and did not change | Decay, or somebody else improved | `refresh-blog-post`                 |
| A query newly ranks at 4 to 20                   | Striking distance, unplanned     | `refresh-blog-post`                 |
| Two of our URLs rank for one query               | Cannibalisation                  | Merge, or differentiate the targets |
| A competitor page appeared where we were         | A specific page to beat          | `plan-blog-content`                 |

**Targeted branch.** When the user asked one narrow question, answer that
question and nothing else. Check the catalog first: if a snapshot from the last
few weeks already answers it, say so, quote it, and ask whether it is worth
re-buying. Then append the run to the ledger anyway, because a targeted answer is
still research and the next run should know it happened.

### 6. Turn rows into decisions

Rows are not a finding. [references/playbook.md](references/playbook.md) has the
reasoning and the honest limits. These five decide most runs, and they are here
rather than in the reference file because a rule you have to fetch is a rule you
will skip.

- **Cluster on SERP overlap, not on wording.** Two queries whose top ten share
  three or more URLs are one post, because Google has already decided they are
  one information need. Writing both is how a site competes with itself. Zero or
  one shared URL means two posts.
- **Intent decides the format before volume decides anything.** An informational
  query the roster can answer completely is worth more to this blog than a
  transactional query with ten times the volume, because the transactional SERP
  wants a product page and will not give the slot to a post.
- **Read the SERP, not the difficulty score.** A page of forum threads is an
  opening. A page of documentation from the vendor whose product the query names
  is not, at any difficulty score.
- **An AI Overview changes the target, not the decision.** Its presence means the
  click is going to be harder to win and the citation is the prize, which is the
  format `write-blog-post` already produces. Absence is not a reason to skip a
  query the site should own.
- **One AI citation measurement is not a fact.** These models are
  non-deterministic, so a single reading is a sample. Report the baseline as a
  sample with its date and prompt set attached, and never report a change from
  one measurement to one other measurement as a trend.

Then rank what survives by what the roster can credibly write, then by what
completes a hub too thin to index, and put raw volume last. That order comes from
`plan-blog-content` and this skill feeds it, so contradicting it here would just
produce a plan that gets reordered.

### 7. Write the catalog back

[references/catalog.md](references/catalog.md) has the exact file shapes. The
three rules that matter:

- **Snapshots are immutable.** Date-prefixed, never edited, never deleted. A
  rewritten snapshot destroys the only baseline the next run had.
- **The ledger is append-only**, one row per run, with the cost actually spent.
- **`baseline.md` is rewritten every run**, and git holds the previous version,
  which is what makes the diff reviewable by a person.

Store the request alongside the response. A snapshot without the exact body that
produced it cannot be re-asked identically, and a comparison between two
different questions is worse than no comparison, because it looks like a result.

`content/research/` sits outside `content/blog/`, so the content adapter never
reads it and none of it can become a published page by accident.

### 8. Report, and hand off

Report in this shape:

1. **What was already known**, from the catalog, and what this run added.
2. **What moved**, on a delta run, with the previous value beside the new one.
3. **The decisions**, as a short ranked list, each with the row that supports it.
4. **What was spent**, against the estimate, and the balance now.
5. **What is missing and why**, naming any section that a degraded state removed.

Then hand off by name rather than continuing:

- Clusters and priorities go to `plan-blog-content`, which turns them into
  `content/editorial-plan.md` with the link graph settled.
- Decayed and striking-distance posts go to `refresh-blog-post`.
- New posts go to `write-blog-post`, carrying the People Also Ask phrasing as the
  headings and the cited sources already found.

**What this cannot tell you.** DataForSEO measures the SERP and the answer, not
the cause. Nothing here establishes that a change in the writing produced a change
in the numbers, and the strongest published correlates of AI visibility are
off-site anyway. A research run that reports a rank movement as the result of last
month's posts has invented a causal claim out of two measurements, which is
exactly the kind of unsupported number `agentblog audit` exists to catch in the
posts themselves. Report what was observed, and say what is unexplained.
registry/agent/skills/dataforseo-research/references/setup.md
# Getting access to DataForSEO

Reference material for `dataforseo-research`. Read it when the access ladder in
SKILL.md step 2 does not reach a working call, or when a call starts failing and
you need to work out which layer broke.

> **License.** This file is prose documentation, licensed CC BY 4.0. See
> `LICENSE-CONTENT` in the AgentBlog repository.

## Contents

1. The two access paths, and which to prefer
2. Getting credentials
3. Where the credentials live, and the trap
4. Path A: the MCP server
5. Path B: REST with curl
6. Verifying without spending anything
7. Reading a failure
8. Degraded modes, and what to do in each

---

## 1. The two access paths, and which to prefer

DataForSEO is one HTTP API. There are two ways to reach it, and they cost the
same because the MCP server is a pass-through to the same account.

|                    | MCP server                                | REST with curl               |
| ------------------ | ----------------------------------------- | ---------------------------- |
| Setup              | One config file, then restart the session | None beyond credentials      |
| Discovery          | Tools appear with typed parameters        | You supply the path and body |
| Response size      | Trimmed by default, see section 4         | Trimmed only if you ask      |
| Endpoint coverage  | 90 tools, some on stale paths             | Every documented endpoint    |
| Failure visibility | Errors arrive as text, not tool failures  | Status code is in the body   |

**Prefer MCP when it is already configured. Prefer REST when it is not.**
Configuring an MCP server requires a session restart to take effect, so a skill
that stops to install one has ended the run. REST works in the same turn.

Never set up the MCP server without asking. It writes to the user's Claude Code
configuration, which is theirs.

## 2. Getting credentials

Registration is at https://app.dataforseo.com/register and the credentials are on
the API Access tab at https://app.dataforseo.com/api-access.

**The API password is generated by DataForSEO and is not the account password.**
A user who types their login password into the config gets a server that connects
and then returns 401 on every call, which is the most common setup failure.

New accounts carry $1 of credit, which is real money and enough for a first
baseline run if you spend it deliberately. The minimum top-up after that is $50.
Email or phone verification has to be completed first, or every call returns
status `40104`.

Do not create a second account to get another $1. DataForSEO treats it as a terms
violation and suspends both, reported as status `40201`.

## 3. Where the credentials live, and the trap

These are secrets. The rule is the one AgentBlog already applies to
`INDEXNOW_KEY` and `AGENTBLOG_REVALIDATE_SECRET`: they belong in a file git does
not track.

**The trap.** Claude Code expands `${VAR}` in `.mcp.json` from the shell
environment of the process that launched it. It does not read `.env.local`, and
AgentBlog puts secrets in `.env.local`. So the natural move, putting the
credentials where every other AgentBlog secret lives and referencing them from
`.mcp.json`, produces a server that reports itself connected and fails
every call with a 401.

The MCP server never validates credentials at startup. It builds a syntactically
valid Basic header out of two empty strings, so the process starts cleanly and
`claude mcp list` says the server is connected. Connected does not mean
authenticated, and nothing tells you which one you have until a call fails.

Two arrangements work. Say which one you are using rather than assuming.

- **Shell export**, for the MCP path. The user exports both variables in the
  shell profile, then starts Claude Code from that shell. `.mcp.json` refers to
  them as `${DATAFORSEO_USERNAME}` and `${DATAFORSEO_PASSWORD}`, so the file
  itself holds no secret and is safe to commit.
- **`.env.local`**, for the REST path. `curl` reads it because you source it in
  the same command. This is the arrangement that matches the rest of AgentBlog,
  and it is the reason the REST path is worth keeping even after MCP works.

Check `git check-ignore .env.local` before writing anything into it. If git
tracks it, stop and say so. A committed API password is a bill somebody else can
run up.

## 4. Path A: the MCP server

The package is `dataforseo-mcp-server` on npm. It requires **Node 20 or newer**,
which contradicts both its own README and the DataForSEO landing page. On Node 18
it installs and then fails at runtime.

Project scope, credentials by reference, pinned version:

```json
{
  "mcpServers": {
    "dataforseo": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "dataforseo-mcp-server@2.9.11"],
      "env": {
        "DATAFORSEO_USERNAME": "${DATAFORSEO_USERNAME}",
        "DATAFORSEO_PASSWORD": "${DATAFORSEO_PASSWORD}",
        "ENABLED_MODULES": "DATAFORSEO_LABS,SERP,AI_OPTIMIZATION,ONPAGE"
      }
    }
  }
}
```

Four things in that block are load-bearing.

- **`ENABLED_MODULES` is not optional in practice.** Left unset it loads all ten
  modules and 90 tools into the session. The four above are what this skill uses.
  The full set is `AI_OPTIMIZATION`, `SERP`, `KEYWORDS_DATA`, `ONPAGE`,
  `DATAFORSEO_LABS`, `BACKLINKS`, `BUSINESS_DATA`, `DOMAIN_ANALYTICS`,
  `CONTENT_ANALYSIS`, `MERCHANT`. A name outside that list is fatal rather than
  ignored: the server throws at startup and presents as a failed connection with
  no useful message.
- **The version is pinned.** `npx -y dataforseo-mcp-server` floats to whatever is
  latest at launch, so the tool surface can change between sessions.
- **The credentials are references.** See section 3.
- **`type` is `stdio`.** The hosted server at `https://mcp.dataforseo.com/mcp` is
  the alternative, taking `Authorization: Basic` as a header. Both `/mcp` and
  `/http` are served, and DataForSEO's own pages disagree about which to use.

Do not tell a user to run `claude test-mcp`. DataForSEO's help centre recommends
it and the command does not exist. `claude mcp list` is the real one.

**Response trimming.** The server appends `.ai` to every endpoint path unless
`DATAFORSEO_FULL_RESPONSE=true`. That is a DataForSEO server-side feature: it
strips empty fields, drops `position` and `xpath`, rounds floats, and **defaults
`depth` and `limit` to 10**. It costs the same as the untrimmed response. Leave
it on. If a field you need is missing, that is the first thing to suspect.

**Five LLM Mentions tools are on stale paths.** At 2.9.11 the server calls
`/llm_mentions/search/live`, `/aggregated_metrics/live`,
`/cross_aggregated_metrics/live`, `/top_domains/live`, and `/top_pages/live`. The
documented paths are `/search_mentions/live`, `/target_metrics/live`,
`/multi_target_metrics/live`, `/top_mentioned_domains/live`, and
`/top_mentioned_pages/live`. Whether DataForSEO still aliases the old names could
not be established from outside. If those tools return a not-found error, use
REST with the documented paths in `references/endpoints.md`.

## 5. Path B: REST with curl

Basic auth is the only mechanism. There is no token exchange and no API key
header.

```bash
set -a; [ -f .env.local ] && . ./.env.local; set +a

curl -s -X POST "https://api.dataforseo.com/v3/dataforseo_labs/google/bulk_keyword_difficulty/live" \
  -u "$DATAFORSEO_USERNAME:$DATAFORSEO_PASSWORD" \
  -H 'Content-Type: application/json' \
  -d '[{"keywords":["headless cms"],"location_code":2840,"language_code":"en"}]'
```

Three shapes to get right, each of which fails loudly the first time and quietly
later:

- **The body is always an array**, even for one task. A bare object returns
  `40503`.
- **Live endpoints take exactly one task per call** and return `40000` for more.
  Task-based endpoints take up to 100 and return `40006` past that.
- **Unknown fields are rejected**, not ignored, with `40506`. Do not send extra
  keys defensively.

Append `.ai` to the path to get the same trimmed response the MCP server gets, at
the same price.

## 6. Verifying without spending anything

Two free instruments, and both should run before the first billable call.

**The account probe.** `GET /v3/appendix/user_data` is free and returns the
account's live balance, its rate limits, and its actual price list per endpoint.

```bash
curl -s -u "$DATAFORSEO_USERNAME:$DATAFORSEO_PASSWORD" \
  "https://api.dataforseo.com/v3/appendix/user_data"
```

Read three things out of it: `money.balance`, so you know whether the run can
finish; the `price` map, so cost estimates use the account's real prices rather
than the published ones; and `llm_mentions_subscription_expiry_date`, which tells
you whether the AI citation half of this skill is available at all.

**The sandbox.** Swapping the host to `https://sandbox.dataforseo.com` runs any
call for free against dummy data with the identical response shape. Use it to
check that a request body is well formed before paying for the real one. The MCP
server cannot reach it, because the base URL is hardcoded with no override, so
sandbox verification is a REST-only move.

## 7. Reading a failure

DataForSEO puts `status_code` at two levels, and **the top-level one being
`20000` does not mean the task succeeded**. Check `tasks_error`, then each
`tasks[i].status_code`.

Through the MCP server this gets worse: a failed call is returned as a text block
beginning `Error:` rather than as a tool failure, so an agent that only checks
whether the tool call threw will read the error as data and report a finding
based on nothing.

| Code                      | Meaning                           | Action                                         |
| ------------------------- | --------------------------------- | ---------------------------------------------- |
| `20000`                   | Success                           | Continue                                       |
| `40100`                   | Not authorised                    | Credentials wrong or unexpanded. Section 3     |
| `40104`                   | Account not verified              | User completes email or phone verification     |
| `40102`                   | No search results                 | Not an error. The query has no data            |
| `40200`, `40210`          | Balance empty, insufficient funds | Stop. Do not retry                             |
| `40203`                   | Cost limit hit                    | Stop. The user set this in the dashboard       |
| `40204`                   | Not entitled                      | Subscription required. Record it, do not retry |
| `40202`                   | Over 2,000 calls per minute       | Back off                                       |
| `40209`                   | Over 30 concurrent requests       | Back off. This one fires first                 |
| `40501`, `40503`, `40506` | Bad request body                  | Your bug. Fix it, do not retry                 |
| `40505`                   | Stale location code               | Refetch the location list for that API         |
| `50401`                   | Live task passed 120 seconds      | Retry once, then narrow the request            |

Retry `40103`, `50000`, `50301`, `50303`, `50401`, `50402`. Never retry a `405xx`
or a funds error: the first is your request and the second needs a human.

## 8. Degraded modes, and what to do in each

Each of these is a state where part of this skill works and part does not. Name
the state in the report. **Do not silently fall back to a smaller run and present
it as a complete one.**

| State                        | Detection                                     | What still works                                                            |
| ---------------------------- | --------------------------------------------- | --------------------------------------------------------------------------- |
| No credentials anywhere      | The SKILL.md step 2 ladder reaches nothing    | Nothing. Stop and give the user section 2                                   |
| Credentials, no MCP server   | Env vars set, no matching tools               | All of it, over REST                                                        |
| MCP connected but 401        | Tools exist, every call returns 401           | Switch to REST and tell the user their expansion is broken                  |
| No LLM Mentions subscription | `40204`, or the expiry date is absent or past | Everything except AI citation measurement. Say which sections are missing   |
| Balance too low for the plan | `money.balance` below the estimate            | Narrow to the highest-value calls, get the user to approve the shorter plan |
| Balance empty                | `40200` or `40210`                            | Nothing billable. The free probes still answer                              |

When a state removes a section, the run report says which section is missing and
why. A research report that quietly omits the AI citation half reads exactly like
one where the site has no AI citations.
registry/agent/skills/dataforseo-research/references/endpoints.md
# The endpoint cookbook

Reference material for `dataforseo-research`. Read it when you are choosing a
call, and before every call that costs more than a cent.

> **License.** This file is prose documentation, licensed CC BY 4.0. See
> `LICENSE-CONTENT` in the AgentBlog repository. Prices and parameters are
> DataForSEO's and are quoted from https://docs.dataforseo.com/v3/ as read in
> August 2026. Verify them against the account's own price list before spending.

## Contents

1. How to call these, whether over MCP or REST
2. Shared parameters, and the two that are billed
3. Finding candidate queries
4. Qualifying them
5. Competitors, and the gap
6. What the site already ranks for
7. AI Overviews in the SERP
8. Are we cited in AI answers
9. Reading our own pages
10. The price list
11. Traps that cost money quietly

---

## 1. How to call these, whether over MCP or REST

**Do not hardcode MCP tool names.** They are not stable, and at least five of
them currently point at endpoint paths DataForSEO has renamed. List the tools
available in the session, match on the distinctive part of the endpoint path
(`keyword_ideas`, `ranked_keywords`, `search_mentions`), and use what is
actually there.

The REST paths below are the contract. They are read from DataForSEO's own
machine-readable index at https://docs.dataforseo.com/v3/llms.txt and they are
what the MCP tools are wrappers over. When an MCP tool disagrees with a path
here, the path here is the one that is documented.

Every path is prefixed `https://api.dataforseo.com/v3/`. Every POST body is a
JSON array of task objects. Append `.ai` to any path for the trimmed response.

## 2. Shared parameters, and the two that are billed

| Parameter       | Notes                                                                    |
| --------------- | ------------------------------------------------------------------------ |
| `location_code` | Integer. United States is `2840`. Each API family has its own list       |
| `language_code` | `en`. Pair it with the location, never omit it                           |
| `limit`         | Labs default 100, max 1000. The trimmed response defaults it to 10       |
| `offset`        | Paging. LLM Mentions caps at 1000000, then use `search_after_token`      |
| `filters`       | A nested array, not an object. Maximum 8 conditions                      |
| `order_by`      | Array, maximum 3 rules, written as `"field,desc"`                        |
| `tag`           | Your own label, echoed back. Use the run date so responses are traceable |

Filters are nested arrays joined by string operators, which is the shape people
get wrong most often:

```json
[["keyword_info.search_volume", ">", 50], "and", ["keyword_properties.keyword_difficulty", "<", 40]]
```

Look the legal field names up per endpoint at `/dataforseo_labs/available_filters`,
which is free. Guessing a field name returns `40501` and wastes the call.

**Two parameters change the price rather than the result.**
`include_clickstream_data: true` doubles a Labs request. `depth` on a SERP call
is billed per ten results and rounds up, so `depth: 11` costs what `depth: 20`
costs.

## 3. Finding candidate queries

Three Labs endpoints expand a seed, and they do different things. Picking the
wrong one is the most common waste in a first run.

| Endpoint                                          | Returns                                                               | Use it when                                                  |
| ------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
| `dataforseo_labs/google/keyword_suggestions/live` | Long-tail queries **containing** the seed                             | You have the right head term and want its tail               |
| `dataforseo_labs/google/related_keywords/live`    | The "searches related to" graph, expanded by `depth`                  | You want adjacent phrasing you would not have guessed        |
| `dataforseo_labs/google/keyword_ideas/live`       | Keywords in the same **category** as the seeds, need not contain them | You are mapping a subject you do not yet have vocabulary for |

`related_keywords` takes `depth` 0 to 4, returning roughly 1, 8, 72, 584, and
4680 keywords. **Depth 2 is the working default.** Depth 3 and 4 are how a
research run turns into a five-figure row count nobody reads.

For a blog, run `keyword_ideas` once per cluster on two or three seeds, then
`keyword_suggestions` on the handful of terms that survive. Both cost
$0.012 per call plus $0.00012 per row.

## 4. Qualifying them

Two endpoints take up to 1,000 keywords in a single call, which makes them the
cheapest per-keyword instruments in the whole API. Batch aggressively.

- `dataforseo_labs/google/bulk_keyword_difficulty/live` returns difficulty 0 to
  100 for up to 1,000 keywords.
- `dataforseo_labs/google/search_intent/live` returns intent and a probability
  for up to 1,000 keywords.

`dataforseo_labs/google/keyword_overview/live` returns volume, CPC, competition,
intent, and monthly series together, but it **caps at 700 keywords**, not 1,000.
Assuming a uniform cap fails on exactly this endpoint.

For volume specifically, `keywords_data/google_ads/search_volume/live` is Google
Ads data at $0.09 per call for up to 1,000 keywords. That is cheaper per keyword
than anything in Labs when all you need is volume.

**Intent is worth more than volume for a blog.** A 90-a-month informational query
that the roster can answer completely is worth more than a 5,000-a-month
transactional one that wants a product page.

## 5. Competitors, and the gap

Start by finding out who the actual competitors are rather than assuming. The
sites competing for the queries are usually not the businesses competing for the
customers.

- `dataforseo_labs/google/competitors_domain/live` ranks domains by how much
  their ranked keyword set overlaps ours.
- `dataforseo_labs/google/serp_competitors/live` does the same for a specific
  keyword set, which is the better instrument once clusters exist.

Then find the gap:

- `dataforseo_labs/google/domain_intersection/live` returns keywords where two
  domains both rank. Invert it with filters to get what they rank for and we do
  not, which is the content gap.
- `dataforseo_labs/google/relevant_pages/live` returns a competitor's pages
  ranked by traffic, which tells you which of their posts to study.
- `dataforseo_labs/google/page_intersection/live` does the same comparison at URL
  level rather than domain level. It is the one to reach for once you know which
  of their posts holds the cluster, because it answers what that page ranks for
  and ours does not, which is a brief. The domain-level version answers a question
  no single post can act on.

## 6. What the site already ranks for

`dataforseo_labs/google/ranked_keywords/live` returns every keyword a domain,
subdomain, or single page ranks for, with positions and SERP features.

Run this on the user's own domain in the first session. It is routinely the most
surprising output of a baseline run, because a blog usually ranks for a set of
queries nobody targeted, and those are the cheapest wins available: positions 4
to 20 on a query the site already half-answers beat a new post on a query it does
not answer at all.

`dataforseo_labs/google/domain_rank_overview/live` gives the distribution and
traffic estimate in one cheap call.

**The historical variants cost ten times their siblings.**
`historical_rank_overview`, `historical_bulk_traffic_estimation`, and
`domain_metrics_by_categories` are $0.12 per call plus $0.0012 per row, against
$0.012 plus $0.00012. Do not reach for them to answer a question the snapshot
files in `content/research/snapshots/` already answer, because after the second
run this skill has its own history and does not need to buy DataForSEO's.

## 7. AI Overviews in the SERP

`serp/google/organic/live/advanced` returns `ai_overview` as one item type
alongside `organic`, `featured_snippet`, and `people_also_ask`.

**`load_async_ai_overview` defaults to `false`, and with the default you get AI
Overview items from cache only.** Google loads these asynchronously, so the
default silently under-reports how often an AI Overview appears. Set it to
`true`. It adds $0.002, refunded when the element turns out not to be there.

This is the single most important parameter in this file. A run that leaves it
alone produces a report saying AI Overviews are rare for these queries, which is
a statement about DataForSEO's cache rather than about Google.

`people_also_ask_click_depth` from 1 to 4 expands the People Also Ask box into
real question phrasing at $0.00015 per click, refunded for clicks not performed.
That phrasing is the raw material for question-format headings, and it is the
cheapest source of real user wording in the API.

Google AI Mode has its own endpoint at `serp/google/ai_mode/live/advanced`, at
$0.004 live. Its `keyword` accepts up to 700 characters, because the queries
people put into AI Mode are sentences.

## 8. Are we cited in AI answers

This is the part of the API that measures the thing AgentBlog exists to improve.
It is also the most expensive per call and the least stable per measurement, so
read section 11 and the playbook before spending here.

| Endpoint                                                  | Returns                                       |
| --------------------------------------------------------- | --------------------------------------------- |
| `ai_optimization/llm_mentions/target_metrics/live`        | Citation metrics for one target, ours         |
| `ai_optimization/llm_mentions/multi_target_metrics/live`  | The same for several targets at once          |
| `ai_optimization/llm_mentions/top_mentioned_domains/live` | Who owns the answers in this subject          |
| `ai_optimization/llm_mentions/top_mentioned_pages/live`   | Which individual pages get cited              |
| `ai_optimization/llm_mentions/search_mentions/live`       | The underlying prompts and answers            |
| `ai_optimization/llm_mentions/top_mentioned_brands/live`  | Which brands own the subject, ours among them |
| `ai_optimization/llm_mentions/historical/live`            | DataForSEO's own series for a target          |
| `ai_optimization/llm_mentions/timeseries_delta/live`      | Change over a period                          |
| `ai_optimization/llm_mentions/timeseries_new_lost/live`   | What was gained and lost between two points   |

The last three are the exception to the rule that this skill buys its own history.
On a first run there is nothing in `content/research/snapshots/` to compare
against, so DataForSEO's series is the only past available and is worth the call
once. From the second run on, the snapshots answer the same question for nothing,
and the reason to keep buying these is a longer window than the catalog has yet,
not convenience.

`top_mentioned_brands` is the honest version of the share-of-voice number people
ask for. Read it as the standings in a subject rather than as a score.

Each has a `_lite` variant returning a simplified shape for less money. Prefer
the aggregate endpoints and the lite variants. `search_mentions` with `limit: 1000`
is $1.10 for one call, because the pricing is $0.10 per request plus $0.001 per
row.

**The field pair that decides whether the answer is right.** Each result carries
both `sources`, which the model actually cited, and `search_results`, which it
retrieved and may never have used. Counting `search_results` overstates citation
dramatically. `sources` is the number.

Two other fields are worth reading every time. `is_web_search_based` says whether
the model used live retrieval or answered from training, and a citation from
training is not something this quarter's writing caused. `fan_out_queries` is the
set of sub-queries the engine expanded the prompt into, which is the only
observed rather than guessed view of that expansion available here.

**Constraints.** `target` takes up to 10 entities and at least one must be an
`include`. `platform` is optional, and omitting it queries both ChatGPT and
Google AI Overview, which changes the row count you are billed for. ChatGPT data
is **United States and English only**.

`ai_optimization/ai_keyword_data/keywords_search_volume/live` returns an
`ai_search_volume` per keyword for up to 1,000 keywords at $0.01 plus $0.0001 per
row. Read DataForSEO's own note on it before quoting it: the values are
"calculated using statistical data from questions in the People Also Ask SERP
element". It is a modelled proxy for prompt volume, not observed LLM traffic, and
presenting it as the latter is the kind of claim `agentblog audit` exists to
catch elsewhere.

## 9. Reading our own pages

`on_page/content_parsing/live` returns a page's headings, links, and text as
structure. It answers whether the answer capsule is really the first thing under
the heading, and whether the H2s are really questions, against what the server
sent rather than against the MDX source.

`on_page/instant_pages` audits a single page with no crawl task at $0.00015.

Neither replaces the raw `curl` with a bot user agent in the `agentblog-audit`
skill. That check exists precisely because it has no tooling in the path.

## 10. The price list

Published prices, August 2026. **Read the account's real ones from the free
`/appendix/user_data` before estimating**, because they differ by plan.

| Call                                      | Price                                          |
| ----------------------------------------- | ---------------------------------------------- |
| Labs, standard endpoints                  | $0.012 per call plus $0.00012 per row          |
| Labs, historical variants                 | $0.12 per call plus $0.0012 per row            |
| Google Ads search volume, live            | $0.09 per call, up to 1,000 keywords           |
| SERP organic, task based, normal priority | $0.0006 per SERP                               |
| SERP organic, live                        | $0.002 per SERP                                |
| SERP organic, `load_async_ai_overview`    | plus $0.002, refunded if absent                |
| People Also Ask, per click                | $0.00015                                       |
| SERP AI Mode, live                        | $0.004                                         |
| AI keyword search volume                  | $0.01 per call plus $0.0001 per row            |
| LLM Mentions                              | $0.10 per call plus $0.001 per row             |
| LLM Responses, live                       | $0.0006 plus the model provider's token charge |
| OnPage, basic per page                    | $0.00015                                       |

A first baseline run for one blog, done the way the playbook describes, costs
well under a dollar if the AI citation section is aggregates only. It costs
several dollars if `search_mentions` is called at full limit. That difference is
the whole reason the cost gate exists.

**Live costs about 3.3 times the task-based price for the same SERP data.** When
a run needs many SERPs and can wait, post the tasks and collect them. When it
needs one, pay for live.

## 11. Traps that cost money quietly

Each of these produces a plausible answer, so nothing looks wrong.

- **`load_async_ai_overview` left at `false`.** Section 7. Reports cache as
  reality.
- **Counting `search_results` instead of `sources`.** Section 8. Inflates
  citation.
- **A search operator in a `keyword`.** `site:`, `intitle:`, `inurl:` and eleven
  others multiply the SERP price by **five**, with no warning. Filter after the
  call instead.
- **`include_clickstream_data: true`.** Doubles a Labs request for data a blog
  workflow rarely reads.
- **`depth` above 10 on SERP calls.** Billed per ten and rounded up.
- **The historical Labs endpoints.** Ten times the price of the snapshot you
  already store.
- **`limit: 1000` on LLM Mentions.** $1.10 per call.
- **Reading only the top-level `status_code`.** It can be `20000` over a failed
  task. Check `tasks_error` and each `tasks[i].status_code`.
- **Treating one AI citation measurement as a fact.** The playbook covers why.
registry/agent/skills/dataforseo-research/references/playbook.md
# The research playbook

Reference material for `dataforseo-research`. Read it when you are deciding what
a set of rows means, when you are choosing between two queries that both look
fine, or when a user asks why a recommendation is a recommendation and deserves a
real answer rather than an assertion.

> **License.** This file is prose documentation, licensed CC BY 4.0. See
> `LICENSE-CONTENT` in the AgentBlog repository. Figures quoted from third-party
> research are attributed and remain those authors' work.

> **Scope.** No endpoint paths, parameters, or prices live here. Those are in the
> endpoint cookbook beside this file. This file is about what to conclude.

## Contents

1. What a research run is actually for
2. The evidence, graded, including what is only convention
3. Harvest before you hunt
4. Clustering on SERP overlap
5. Intent, and what a blog can win
6. Reading a SERP as an opportunity
7. The gap is a page-level question
8. Measuring AI citation without lying to yourself
9. Cadence, and what a comparison has to hold constant
10. Anti-patterns, each of which produces a confident wrong answer

---

## 1. What a research run is actually for

A blog gets traffic and citations for queries where three things are true at
once: somebody asks it, this roster can answer it better than what currently
holds the page, and the answer belongs in a blog post rather than on some other
kind of page.

Every instrument in this skill exists to test one of those three. Volume tests the
first. Difficulty, the SERP itself, and the competitor's page test the second.
Intent tests the third. A run that only measures volume has tested one third of
the question and will happily recommend a query the site cannot win with a format
that does not fit.

The output of a run is not a keyword list. It is a ranked set of decisions, each
with the row that supports it, and each pointing at a skill that acts on it.

## 2. The evidence, graded, including what is only convention

Say which of these three a claim is, whenever it decides something expensive.

**Measured here.** Anything read out of a DataForSEO response this run. Positions,
SERP composition, the presence of an AI Overview, the citation counts. These are
observations of a sample at a moment.

**Published research.** Two figures worth carrying, both from Ahrefs, both
correlational and industry rather than peer reviewed:

- Across 75,000 brands, the strongest correlates of AI visibility were off-site:
  YouTube mentions at Spearman 0.737 and branded web mentions at 0.664, against
  backlinks at 0.218 (https://ahrefs.com/blog/ai-brand-visibility-correlations,
  12 December 2025). This is the single most important number in this file,
  because it bounds what any amount of research inside this repository can do.
- Across 174,048 pages in 560,346 AI Overviews, the correlation between word count
  and being cited was 0.04, with 53.4% of cited pages under 1,000 words
  (https://ahrefs.com/blog/short-vs-long-content-in-ai-overviews/, 3 December
  2025). Word count is not a lever. Do not recommend a length.

**Practitioner convention.** Widely used, not established by a study anybody can
point at. The three-shared-URL clustering threshold in section 4 and the
positions 4 to 20 striking-distance band in section 3 are both conventions. They
are good defaults and they are not findings. Do not present either as research,
and do not let a user's specific SERP lose an argument to a rule of thumb.

## 3. Harvest before you hunt

The first question of a first run is not "what should we write". It is "what does
this site already almost rank for", and it is asked with one call against the
user's own domain.

A blog that has been publishing for any length of time ranks for queries nobody
targeted, because posts get read for reasons their authors did not plan. Those
queries are the cheapest inventory available:

- **Positions 4 to 20.** The page is already considered relevant. Something
  specific is missing, and finding it is a reading job on one existing post, not a
  writing job on a new one. The conventional band is 11 to 20, which is page two.
  Widen it to 4, because moving from 7 to 3 changes the click share more than
  moving from 18 to 12 does, and both are within reach of the same edit.
- **A post that fell and did not change.** The post is the same, so the SERP moved
  around it. Either the query drifted, or somebody published something better.
  Look at what now holds the positions above it before touching the post.
- **Two of our URLs on one query.** Cannibalisation. Neither page accumulates the
  signals that would have made one of them the answer. Decide which page owns the
  query, then either merge the other into it or retarget it. Merging is usually
  right, and the internal links have to move with it.

Each of these ends at `refresh-blog-post`, not at `write-blog-post`. A refresh of
a post with inbound links and existing position beats a new post with neither,
and the whole point of running this step first is that the comparison is available
before anybody commits to writing.

## 4. Clustering on SERP overlap

Group queries by what the search engine returns for them, not by how they are
worded.

**The rule.** Two queries whose top ten results share three or more URLs belong
on one page. Zero or one shared URL means two pages. Two is a judgement call, and
the tie-breaker is whether one post could answer both without changing subject.

**Why it beats grouping by wording.** String similarity is a guess about intent.
The SERP is the engine's already-published answer to that guess. When two
differently worded queries return the same pages, the engine has decided they are
one information need, and a site that publishes two posts against them splits its
own signals between two pages that then compete. When two similarly worded
queries return different pages, one post covering both will satisfy neither.

**What this decides beyond the post count.** The query in the cluster with the
broadest SERP overlap across the others is the pillar. The rest are supporting
posts, and they link up to it. That is exactly the structure `plan-blog-content`
needs, and deriving it from measured overlap is better than deriving it from
which query has the highest volume, because volume does not tell you which page
the engine would treat as the parent.

Clustering needs one SERP read per query, so it is the step that decides the
budget. Cluster the ten to twenty finalists, not the six hundred candidates.

## 5. Intent, and what a blog can win

Intent is worth more than volume for a blog, because it decides whether a post is
even the right object.

- **Informational.** The blog's home ground. A post can hold the page and can be
  the thing an answer engine quotes.
- **Commercial investigation.** Comparisons, alternatives, "best" queries. A post
  can win these, and the SERP will tell you whether the current holders are
  editorial or vendor pages. Worth doing when the roster has real grounds for an
  opinion, and a trap when it does not, because an uninformed comparison is the
  most obviously thin thing a blog can publish.
- **Transactional and navigational.** Not a blog post. The SERP wants a product
  page, a pricing page, or a specific brand's own site. Recommending a post here
  produces an article that ranks nowhere and serves nobody.

A 90-a-month informational query this roster can answer completely is worth more
than a 5,000-a-month transactional one it cannot, and a run that sorts by volume
inverts exactly that.

## 6. Reading a SERP as an opportunity

The difficulty score is a summary. The SERP is the evidence, and it answers a
question the score cannot: what would we have to beat.

Read four things off it:

- **Who holds the top five.** Forum threads and thin aggregators are an opening.
  Primary documentation from the vendor the query names is not, at any difficulty
  score, because no post outranks a product's own reference for a question about
  that product.
- **Which SERP features fire.** A featured snippet is a specific extractable
  answer somebody already wrote, and the shape of it tells you what format wins.
  People Also Ask is the cheapest source of real user phrasing in the whole API,
  and that phrasing becomes question-format H2s rather than being paraphrased into
  marketing wording.
- **Whether an AI Overview appears.** Its presence means the click is harder and
  the citation is the prize, which is the format `write-blog-post` already
  produces. It is not a reason to skip a query the site should own, and its
  absence is not a reason to relax the format, because the element appears and
  disappears on the same query over time.
- **Whether the results answer the question the query asks.** When the top ten
  are all adjacent but none of them answers it directly, that is the best finding
  a research run can produce, and no metric in the API reports it.

## 7. The gap is a page-level question

Domain-level competitor overlap tells you who to study. It does not tell you what
to write, because a domain does not rank, a page does.

Work down: find the competing domains by keyword overlap, take the top three, then
pull each one's pages ranked by traffic. Those pages are the brief. Read the ones
that hold the queries in the clusters, and record for each what it actually covers
and where it stops. Where it stops is the post.

Two failure modes here:

- **Studying the businesses instead of the pages.** The sites competing for the
  queries are usually not the companies competing for the customers. A publisher
  with no product often holds more of the subject's queries than any vendor does.
- **Producing a keyword gap and calling it a content gap.** A list of queries a
  competitor ranks for and we do not is not a plan. Most of that list is queries
  this roster should not write about. The gap worth acting on is a query inside a
  cluster we already own, where their page is the reason we do not hold it.

## 8. Measuring AI citation without lying to yourself

This is the part of the API that measures the thing AgentBlog exists to improve,
and it is the easiest place in this skill to produce a confident wrong number.

**Count `sources`, not `search_results`.** The model retrieves far more than it
cites. `search_results` is what it looked at, `sources` is what it used. Counting
retrieval as citation inflates the number dramatically and in the flattering
direction, which is why nobody catches it.

**Read `is_web_search_based`.** A citation produced from training data is not
something this quarter's writing caused, and reporting it as though it were
attributes an outcome to work that could not have produced it.

**Read `fan_out_queries`.** The sub-queries the engine expanded the prompt into
are the only observed rather than guessed view of that expansion available
anywhere in this stack. They are a better source of post targets than any keyword
endpoint, because they are what the engine actually asked on the user's behalf.

**Treat one measurement as a sample.** These models are non-deterministic: the
same prompt run repeatedly returns different answers with different citations. A
single reading is a draw from a distribution, not a value. Two readings taken a
month apart differ for reasons that include the writing and also include the
weather. Report a baseline with its date, its prompt set, its targets, and its
platform attached, and never report the difference between two single
measurements as a trend.

**Say which platform.** Omitting the platform queries both ChatGPT and Google AI
Overview and changes the row count billed. ChatGPT data is United States and
English only, so a non-US site measuring without saying so is reporting a number
about a market it does not sell in.

## 9. Cadence, and what a comparison has to hold constant

A comparison is only a comparison if the question did not change. Hold the
target, the platform, the location, the language, the prompt set, and the limit
constant between runs, and record all six in the snapshot. Change one of them and
you have a new baseline, not a movement, and the honest thing to do is say so and
start the series again.

Sensible defaults, all adjustable and none of them findings:

| Measurement                           | Cadence                                      | Why not more often                                                                   |
| ------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------ |
| Own ranked keywords                   | Monthly                                      | Positions move daily and the noise swamps the signal below a month                   |
| AI citation aggregates                | Monthly, same prompt set                     | Expensive, and non-deterministic enough that weekly readings mostly measure variance |
| Cluster SERPs                         | When a cluster is being planned or replanned | The composition is stable enough that re-reading it monthly buys nothing             |
| Keyword expansion, difficulty, intent | Once, then when the subject changes          | These move over quarters, and they are already on disk                               |

The rule underneath all four rows: re-buy what moves, read what does not.

## 10. Anti-patterns, each of which produces a confident wrong answer

- **Starting with expansion.** The first call of a first run is against the user's
  own domain. Starting with a seed expansion produces six hundred rows and no idea
  which of them the site is already close to.
- **Sorting by volume.** It inverts section 5 and buries every query worth writing.
- **Recommending a word count.** Correlation 0.04. Section 2.
- **Clustering by wording.** Section 4. Produces posts that compete with each
  other, which is worse than not writing the second one.
- **Reporting `search_results` as citations.** Section 8.
- **Comparing two runs that asked different questions.** Section 9. Worse than no
  comparison, because it looks like a result.
- **Attributing a movement to the writing.** Nothing in this API establishes
  cause, and the strongest published correlates of AI visibility are off-site.
  Report the movement, name the plausible causes, and say which one the data
  cannot distinguish.
- **Re-buying what is on disk.** The catalog exists so the second run is a diff.
  A run that re-collects everything has thrown away the only compounding asset
  this skill produces.
registry/agent/skills/dataforseo-research/references/catalog.md
# The research catalog

Reference material for `dataforseo-research`. Read it before writing anything
into `content/research/`, and before comparing a new measurement against an old
one.

> **License.** This file is prose documentation, licensed CC BY 4.0. See
> `LICENSE-CONTENT` in the AgentBlog repository.

## Contents

1. Why the research lives in the repository
2. The layout
3. `ledger.md`, the index every run reads first
4. `baseline.md`, the current standing answers
5. `snapshots/`, and the rule that makes them worth keeping
6. The `_meta` block, which is what makes a snapshot re-askable
7. Bootstrapping the directory on a first run
8. The comparison protocol
9. Size, and what to do when the directory gets big
10. What must never be written here

---

## 1. Why the research lives in the repository

Because the next agent is not this one.

A research finding held in a conversation dies with the session. The user then
asks the same question next month, a different agent runs a different query
against a different sample, and the two answers disagree for reasons nobody can
reconstruct. That is not research, it is repeated purchasing.

Committing the research to the repository fixes three things at once. Any agent in
any session can read what is already known before spending. Git holds the previous
version of every file, so a change is a reviewable diff rather than an assertion.
And the raw responses stay available, so a later question can often be answered
from disk for nothing.

`content/research/` sits outside `content/blog/`, so the content adapter never
reads it and nothing in it can become a published page by accident. This is the
same placement `content/editorial-plan.md` already uses.

## 2. The layout

```
content/research/
├── README.md                     written once, for the human who finds this directory
├── ledger.md                     append-only index of every run
├── baseline.md                   the current standing answers, rewritten each run
└── snapshots/
    ├── 2026-08-08-account.json
    ├── 2026-08-08-ranked-keywords.json
    ├── 2026-08-08-competitors.json
    ├── 2026-08-08-candidates.json
    ├── 2026-08-08-serp-chunking-strategies.json
    └── 2026-08-08-ai-citations.json
```

Every snapshot filename is `YYYY-MM-DD-<what>.json`, using the date the call was
made rather than the date the file was written. Sorting the directory sorts the
history, and the filenames alone tell a new agent what has been measured without
reading a byte of JSON.

Use these `<what>` names, and add new ones rather than overloading an existing
one: `account`, `ranked-keywords`, `competitors`, `competitor-pages`,
`candidates`, `serp-<cluster-slug>`, `ai-citations`, `ai-prompts`.

## 3. `ledger.md`, the index every run reads first

The ledger is the whole point of the catalog. It is the one file a run must read
before it does anything else, and it is append-only: correct a mistake by adding
a row that says so, never by editing history.

```markdown
# Research ledger

Every DataForSEO run against this project. Append only. Newest last.

Site: https://example.com
Location: 2840 (United States), language `en`, unless a row says otherwise.

| Date       | Type     | Questions asked                                  | Snapshots                                                                        | Spent  | What changed                                                                  |
| ---------- | -------- | ------------------------------------------------ | -------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------- |
| 2026-08-08 | baseline | What do we rank for, who competes, what to write | ranked-keywords, competitors, candidates, serp-chunking-strategies, ai-citations | $0.71  | First run. 214 ranked keywords, 9 in positions 4 to 20, 0 AI citations found  |
| 2026-09-05 | delta    | What moved in rankings and AI citations          | ranked-keywords, ai-citations                                                    | $0.34  | 3 posts gained, 1 fell 6 places without changing, first citation on 2 prompts |
| 2026-09-11 | targeted | Is "rag evaluation metrics" worth a post         | serp-rag-evaluation                                                              | $0.004 | Yes. Top 5 is forum threads and one vendor blog from 2024                     |
```

Then a short prose section under the table for anything a cell cannot hold: a
prompt set that changed and why, a degraded run and which section it lost, a
finding that needed a sentence.

## 4. `baseline.md`, the current standing answers

One file that answers, right now, the questions a run exists to answer. Rewritten
in full every run. Git holds the previous version, and that diff is the most
readable artefact this skill produces, so keep the section order stable between
runs or the diff becomes noise.

```markdown
# Research baseline

Last updated 2026-09-05 from the run of the same date. Every number here is a
sample, with the snapshot that produced it named beside it.

## What the site ranks for

217 keywords, 12 in positions 4 to 20. Source: 2026-09-05-ranked-keywords.json.

| Query | Position | Was | Our URL | Action |
| ----- | -------- | --- | ------- | ------ |

## Who competes for these queries

## The clusters, and the pillar of each

| Cluster | Pillar query | Members | Shared URLs | Intent | AI Overview |
| ------- | ------------ | ------- | ----------- | ------ | ----------- |

## AI citation

Prompt set of 12, ChatGPT and Google AI Overview, United States, English.
Baseline taken 2026-08-08, re-measured 2026-09-05. Source: 2026-09-05-ai-citations.json.
Non-deterministic: read these as samples, not values.

## Open questions this research has not answered
```

That last section is not decoration. It is what stops the next run from starting
over: it says what was deliberately left unmeasured and what it would cost.

## 5. `snapshots/`, and the rule that makes them worth keeping

**A snapshot is immutable.** Never edit one, never delete one, never overwrite one
because a call was re-run the same day. A snapshot is the record of what an API
returned at a moment, and rewriting it destroys the only baseline the next
comparison had. If a call is repeated on the same date, suffix the second one
(`2026-08-08-ranked-keywords-2.json`) and say in the ledger why.

Store the trimmed response, not a summary. The trimmed `.ai` response costs the
same as the full one and is already stripped of the fields nobody reads. A summary
written now cannot answer a question asked later, and the whole reason to keep
these is that later questions are free to answer from disk.

Set `limit` explicitly on every call that goes into a snapshot. The trimmed
response defaults `depth` and `limit` to 10, so a snapshot taken without an
explicit limit is silently ten rows, and a comparison against a later run that set
one will show a gain that is entirely an artefact of the request.

## 6. The `_meta` block, which is what makes a snapshot re-askable

Every snapshot file is an object with two keys: the request that produced it, and
the response. Not the bare response.

```json
{
  "_meta": {
    "run": "2026-09-05",
    "type": "delta",
    "endpoint": "dataforseo_labs/google/ranked_keywords/live.ai",
    "transport": "rest",
    "request": [
      {
        "target": "example.com",
        "location_code": 2840,
        "language_code": "en",
        "limit": 500,
        "tag": "2026-09-05-delta"
      }
    ],
    "status_code": 20000,
    "tasks_error": 0,
    "cost": 0.072,
    "compares_to": "2026-08-08-ranked-keywords.json"
  },
  "response": {}
}
```

`request` is the load-bearing field. A snapshot without the exact body that
produced it cannot be re-asked identically, and a comparison between two different
questions is worse than no comparison because it looks like a result. `compares_to`
names the snapshot this one is a successor of, which turns the directory into a
series rather than a pile.

Record `status_code` and `tasks_error` from the response, both of them. The
top-level status can be `20000` over a failed task, so a snapshot that stored only
the top-level code can be a record of a failure that reads as a success.

## 7. Bootstrapping the directory on a first run

Create all four paths in the same commit as the first run's data, so the directory
is never half-formed.

`README.md` is written once and then left alone. It exists for the human who finds
this directory in a diff and wonders what it is:

```markdown
# Research

DataForSEO research for this blog, kept in the repository so it compounds.

- `ledger.md` is the index of every run. Read it first.
- `baseline.md` is the current standing answer to what we rank for, who competes,
  and what to write next. It is rewritten each run; git holds the previous one.
- `snapshots/` holds the raw API responses, dated and immutable.

The `dataforseo-research` skill maintains all of this. It reads the ledger before
spending anything, so it can tell you what is already known rather than buying it
again. Nothing here is published: the blog only reads `content/blog/`.
```

Do not gitignore this directory. Committing it is the mechanism, not a side
effect. The files carry no credentials, and the responses are data the account
already paid for.

## 8. The comparison protocol

Run this whenever a snapshot has a predecessor.

1. **Confirm the questions match.** Compare the two `_meta.request` bodies field by
   field. Target, platform, location, language, limit, filters, and prompt set all
   have to be identical. If any differs, this is a new baseline. Say so in the
   ledger, start the series again, and do not report a movement.
2. **Diff the rows, keyed on the stable identifier.** Keyword for keyword rows,
   URL for page rows, prompt for citation rows. Never diff by array position: the
   ordering is not stable and a reordered response reads as total churn.
3. **Classify each difference** as new, lost, improved, declined, or unchanged.
   The counts of each are the headline, and they belong in the ledger row.
4. **Separate what moved from what was not asked last time.** A row that is new
   because the earlier run used a smaller limit is not a gain. This is the failure
   that step 1 and the explicit-limit rule in section 5 exist to prevent, and it
   is the most common way a delta run reports a fictional improvement.
5. **Attribute nothing.** Report the movement and the plausible causes. Nothing in
   this data distinguishes a change caused by the writing from a change caused by
   a competitor, an algorithm update, or, for the citation numbers, sampling.

## 9. Size, and what to do when the directory gets big

Snapshots are small. A 500-row trimmed Labs response is on the order of a hundred
kilobytes, so a monthly cadence produces a directory a reader can still browse
after several years, and the git history is the point rather than a cost.

Two limits worth respecting anyway. Do not commit a `search_mentions` response at
`limit: 1000` unless the run needed it, because that is a large file for data
mostly summarised elsewhere. And do not store raw HTML SERP responses: keep the
advanced JSON, which is what every comparison reads.

If the directory ever does need pruning, prune the middle of a series and keep the
endpoints, then record in the ledger which files were removed. Never prune the
oldest snapshot in a series: it is the baseline every long comparison runs
against.

## 10. What must never be written here

- **Credentials.** Not in a `_meta` block, not in a shell snippet copied into the
  ledger, not in a README. They live in the environment or in `.env.local`, and
  `git check-ignore .env.local` is the check to run before touching that file. A
  committed API password is a bill somebody else can run up.
- **A summary in place of a response.** Section 5.
- **An estimate in the ledger's `Spent` column.** Record what was actually spent,
  read back from the account. An estimate recorded as a fact makes the whole
  column useless for budgeting the next run.
- **A finding without the snapshot that supports it.** Every number in
  `baseline.md` names its source file. A number with no source cannot be checked,
  cannot be re-derived, and is indistinguishable from one that was invented.
registry/agent/skills/plan-blog-content/SKILL.md
---
name: plan-blog-content
description: >
  Builds the editorial plan a blog needs before anyone writes: the entity the site
  should be known for, a taxonomy of five to ten category hubs, query clusters
  mined from what people actually search, a pillar and supporting post per cluster
  with the internal link direction decided in advance, and a prioritised backlog
  written to content/editorial-plan.md. Use when asked what to write about, for a
  content or editorial plan, a topic or keyword cluster map, a blog strategy or
  calendar, or when a blog has posts but no structure connecting them.
allowed-tools: Read Write Edit Glob Grep WebSearch WebFetch Bash(npx agentblog *)
license: MIT
compatibility: A Next.js project with AgentBlog installed. Needs web access to mine real queries.
metadata:
  package: agentblog
  homepage: https://agentblog.dev
---

# Plan what the blog is for

A pile of individually good posts does not get cited. Retrieval systems resolve
entities and follow links, so what earns citations is a site that is legibly about
something, with hub pages that have enough on them to be worth indexing and a link
graph that says which post is the authority on what.

This skill produces a plan. It writes no posts. The deliverable is
`content/editorial-plan.md`, and every line of it has to be specific enough that
`write-blog-post` can start from one row without asking a question.

Be honest with the user about the limit of this work. In Ahrefs' study of 75,000
brands, the strongest correlates of AI visibility were off-site: YouTube mentions
at Spearman 0.737 and branded web mentions at 0.664, against backlinks at 0.218
(https://ahrefs.com/blog/ai-brand-visibility-correlations, 12 December 2025,
industry and correlational). None of that lives in this repository. A perfect
editorial plan is necessary and it is not sufficient, and saying so early is better
than saying it in month four.

```
- [ ] 1. Read what already exists
- [ ] 2. Name the entity
- [ ] 3. Mine real queries
- [ ] 4. Cluster into hubs
- [ ] 5. Assign pillars and supporting posts
- [ ] 6. Check what is already answered well
- [ ] 7. Write the backlog
- [ ] 8. Reconcile the taxonomy
```

### 1. Read what already exists

- `agentblog.config.ts` for `brand.name`, `siteUrl`, and `defaultAuthor`.
- `content/authors.json`. `knowsAbout` and `bio` are the constraint nobody
  remembers: a plan that requires posts this roster cannot credibly write is a plan
  to publish thin content under a real name. If the plan needs expertise the roster
  does not have, the answer is a new author record, not a shrug.
- `content/categories.json` for the current taxonomy.
- Every post in `content/blog/`. Record the target query, the category, and both
  directions of its internal links.

```bash
npx agentblog audit --stale --days 90
```

Run it before planning anything new. It ranks posts by `dateModified` age weighted
by inbound internal links, so it tells you which existing posts are worth more than
a new one. Refreshing a post with authority usually beats writing a fourth post
nobody links to.

### 2. Name the entity

Write one sentence: **this site is the place to read about X**. Then check it
against three things: the products or services the business actually sells, the
expertise in `content/authors.json`, and what a reader arriving from a generative
answer would expect to find.

Narrower wins. A site about "retrieval evaluation for RAG systems" resolves as an
entity. A site about "AI" does not resolve as anything, and it competes with every
publisher on earth for queries it cannot win.

This sentence decides everything after it. Get the user to agree to it explicitly
rather than inferring it and proceeding.

### 3. Mine real queries

**Check `content/research/` first.** If `dataforseo-research` has run, its
`baseline.md` already holds measured clusters, the queries this site is close to
ranking for, and the People Also Ask phrasing, all of it better evidence than
anything this step can produce on its own. Use it, and use this step only for what
it did not cover. If that directory does not exist and the project has a
DataForSEO account, running that skill first is worth the delay: it is the
difference between a plan built on measurement and a plan built on inference.

Otherwise, `WebSearch` the entity's obvious head terms. Collect phrasing from
People Also Ask boxes, forum threads, and the questions competitors' posts
actually answer in their H2s. If the project has Search Console access, that is
better than all of it.

Two rules:

- **Do not invent phrasing.** The point of this step is the gap between how a
  practitioner phrases a question and how a marketer would. The practitioner's
  phrasing is the one that matches a retrieval query.
- **Record the question, not the keyword.** "how long should a rag chunk be" is a
  target. "rag chunk size" is a keyword, and a keyword does not tell you what the
  post has to answer.

Collect 40 to 80 real questions before clustering. Fewer and the clusters are
guesses.

### 4. Cluster into hubs

Group the questions by what a reader would consider one subject. Each surviving
group becomes a category in `content/categories.json`.

**Five to ten categories, and no more.** Each is an indexable hub page. A hub with
two posts on it is a crawl liability rather than an asset, so a category needs at
least four to six posts planned before it earns a slug. Categories that cannot
reach that go back into a neighbouring cluster.

Every category needs a real two-sentence description saying what belongs in it and
what does not, because the hub page is indexable published prose and the schema
rejects an empty one.

### 5. Assign pillars and supporting posts

Within each cluster, decide which single post is the authority and which support
it. Then decide the link direction **now**, in the plan, rather than post by post:

- Every supporting post links up to its pillar, with anchor text naming the
  pillar's topic.
- The pillar links down to every supporting post.
- Cross-cluster links happen where the subject genuinely crosses, and not to hit a
  count.

Deciding this in advance is the entire reason to plan. Decided post by post, the
graph becomes whatever the most recent post felt like linking to, and the pillar
never accumulates the inbound links that make it the authority.

Every post in the plan must have at least one planned inbound link before it is
written, because a post with none is an orphan and `agentblog audit` fails it.

### 6. Check what is already answered well

For each planned post, look at what currently ranks and what a generative engine
currently answers. Drop or reframe anything where a well-resourced publisher has
already answered the question completely and you would only be paraphrasing.

Reframe toward what the roster can say and others cannot: a measurement somebody
ran, a failure mode from real operation, a comparison nobody has done, a document
read properly rather than summarised.

**Do not plan by length.** Ahrefs analysed 174,048 pages across 560,346 AI
Overviews and found the correlation between word count and being cited was 0.04,
which is no relationship at all, with 53.4% of cited pages under 1,000 words
(https://ahrefs.com/blog/short-vs-long-content-in-ai-overviews/, 3 December 2025).
Plan the question each post answers. Length follows.

### 7. Write the backlog

Write `content/editorial-plan.md`. It sits outside `content/blog/`, so the content
adapter never reads it and it cannot become a published page by accident.

One table per cluster, plus the entity sentence at the top:

| Target question (as searched) | Role | Category | Author | Links out to | Links in from | Sources already found | Priority |
| ----------------------------- | ---- | -------- | ------ | ------------ | ------------- | --------------------- | -------- |

`Sources already found` is what makes this plan worth more than a list of titles.
When step 3 or step 6 turned up a study, a specification, or a primary document
worth citing, put the URL in the row. The writing skill has a hard rule against
citing anything it did not fetch, so a plan that carries real sources removes the
most expensive part of writing each post.

Prioritise by three things in this order: whether the roster can credibly write it,
whether it completes a hub that is currently too thin to index, and whether anyone
is searching for it. Traffic potential is last, because a hub that never reaches
enough posts never earns the citations any of them would have brought.

### 8. Reconcile the taxonomy

The plan is not finished until `content/categories.json` matches it.

- Adding a category is safe.
- **Removing or renaming a slug that any existing post names in frontmatter fails
  the next build** with `unknown category slug`. Change the posts in the same
  commit, or leave the slug in place until the posts move.
- A fresh install ships four categories that are AgentBlog's own topics. If
  `agentblog-setup` has not replaced them yet, do that here.

Then report: the entity sentence, the categories with their post counts, the
backlog length, which existing posts should be refreshed instead of replaced, and
what the plan cannot do because it is off-site.
registry/agent/skills/write-blog-post/SKILL.md
---
name: write-blog-post
description: >
  Writes or rewrites a blog post that generative engines cite, in a Next.js project
  running AgentBlog: answer-first capsules, question-format H2 headings,
  self-contained sections, statistics and quotations verified at a real source,
  prose that does not read as machine-written, and frontmatter that parses against
  the AgentBlog post schema. Use when asked to write a post, draft an article,
  outline a piece, add a post to the blog, rewrite a post for AI visibility, or
  make a post citable, in any project that has an agentblog.config.ts.
argument-hint: '[topic or target query]'
allowed-tools: Read Write Edit Glob Grep WebSearch WebFetch Bash(npx agentblog *)
license: MIT
compatibility: A Next.js project with AgentBlog installed. Needs web access to verify sources.
metadata:
  package: agentblog
  homepage: https://agentblog.dev
---

# Write a blog post for AI citation

This post has to survive two readers: a person deciding whether to trust it, and a
retrieval system deciding whether to quote it. The rules below serve both. They are
not style preferences, they are the shape that gets extracted.

Assume the shadcn CLI skill already covers the registry and component primitives.
Assume `node_modules/next/dist/docs/` already covers Next.js APIs. This skill covers
only what those do not: how to write the post.

## Rules you may not violate

These are in the body rather than in a reference file, because a rule you have to
fetch is a rule you will skip.

- **Never fabricate a statistic, a quotation, or a source.** If you cannot verify a
  number at a real source you have actually fetched, state the claim qualitatively
  instead ("most", "a majority of", "the largest share") and say where the
  qualitative claim comes from. Do not round an unverified number into precision.
  Do not attribute a quote to a person unless you fetched the page it appears on.
  One invented figure turns this skill from an asset into a liability, because the
  post is then worse than not publishing.
- **Never invent experience.** No anecdote, no first-person story, no "a client we
  worked with", no opinion the source material does not support, no detail added
  for texture. Writing that sounds human by inventing a human is the worst
  available outcome: it is fabrication that also reads as fabrication. Credibility
  here comes from verified evidence and a named author with a real bio.
- **Never use an em dash.** Not one. Use a comma, a colon, parentheses, or a full
  stop and a new sentence. Do not substitute a double hyphen either. The em dash is
  the most recognisable tell of machine-written prose, and a product that sells
  AI-assisted writing cannot ship copy that reads as AI-generated. This covers the
  post body, the frontmatter, the alt text, and anything else you write into the
  repository. `agentblog audit` fails the post on it.
- **Never keyword-stuff.** In the GEO paper's own results table, keyword stuffing
  scored _below_ the unoptimised baseline on Position-Adjusted Word Count. It is
  the one technique measured to make things worse.
- **Never pad to hit a word count.** Length is a proxy for completeness, not a
  target. Padding degrades the retrieval quality of every section it touches,
  because a chunk that wanders retrieves worse than a chunk that does not.

Constructions that are banned outright, with no exceptions:

- "In today's fast-paced world", or any variant of throat-clearing before the answer.
- "It's not just X, it's Y."
- A rhetorical question followed immediately by its own answer as a paragraph opener.
  (Question-format **H2 headings** are required. Rhetorical questions inside prose
  are banned. These are different things.)
- The words "delve", "leverage", "robust", "seamless", "landscape", "tapestry".
- Three-item lists where two items carry the meaning. Cut the filler item.
- "Studies show", "experts agree", "industry reports suggest". Name the source and
  link it, or cut the claim.

The full catalogue of weaker patterns is in `references/voice.md`, and step 9 is
where you work through it.

## Procedure

Copy this into your reply and check items off as you go. Steps 8 through 11 are the
ones that get skipped when the draft feels finished, which is why they are listed.

```
- [ ] 1. Read the project
- [ ] 2. Fix the target query and its sub-questions
- [ ] 3. Outline in question-format H2s
- [ ] 4. Draft answer-first
- [ ] 5. Write self-contained sections
- [ ] 6. Ground every section in evidence
- [ ] 7. Put comparison data in a real table
- [ ] 8. Wire the post into the link graph
- [ ] 9. Revise for voice
- [ ] 10. Write the frontmatter
- [ ] 11. Run agentblog audit, then the judgment checks
```

### 1. Read the project before you write

- Read `agentblog.config.ts` for `brand.name`, `siteUrl`, `locale`, and
  `defaultAuthor`.
- Read `content/authors.json` for the author roster. `author.name` is a person's
  name and nothing else: no job title, no honorific, no publisher name. `jobTitle`
  is a separate field for exactly this reason. Note the author's `bio` and
  `knowsAbout`, because the post has to be something that person could credibly
  have written.
- Read `content/categories.json` for the taxonomy.
- Glob `content/blog/*.mdx` and read the frontmatter of every existing post, plus
  the body of the closest one. You need four things: the brand voice, the category
  and cluster structure, the internal link graph (`relatedPosts` plus in-body
  links), and the house sentence rhythm.

**Categories.** `category` in frontmatter must name a `slug` that exists in
`content/categories.json`, or the build fails with `unknown category slug`. Prefer
an existing category: five to ten fixed categories is the working range, because
each one is an indexable hub page and a hub with two posts on it is a crawl
liability rather than an asset.

A fresh install ships four categories that are AgentBlog's own topics, not the
user's, so on an early post there may genuinely be no fit. When there is none, say
so, propose the new category, and add a record to `content/categories.json` in the
same change as the post:

```json
{
  "slug": "your-new-category",
  "name": "Your New Category",
  "description": "Two sentences saying what belongs in this category and what does not. The hub page is indexable, so this is published prose, and the schema rejects an empty one."
}
```

`slug` is lowercase and hyphen separated. Do not add a category you will put one
post in, and do not add one silently: name it in your report.

### 2. Fix the target query and its sub-questions

Identify the head query the post answers, then the sub-questions a reader who asked
it would ask next. Use `WebSearch` to find the real phrasing people use, from People
Also Ask boxes and forum threads, rather than the phrasing you would invent. The
sub-questions become the H2s.

Read the top two or three results that already rank. You are looking for what they
fail to answer, not for what to restate.

### 3. Outline in question-format H2s

Every H2 is a question a real person would type. Give every H2 and H3 a stable,
lowercase, hyphenated `id` derived from its text. Nest H2 then H3 with no skipped
levels. The title lives in frontmatter and renders as the page's only H1, so the
body starts at H2.

Content that a generative engine cites is about twice as likely to contain a question
mark, and 78.4% of citations tied to questions came from headings rather than body
text (Kevin Indig, 3M ChatGPT responses and 30M citations, reported by Search Engine
Land, 18 February 2026, industry and correlational). The mechanism is more convincing
than the correlation: a heading phrased as a question matches the shape of the query
the retrieval system is matching against.

### 4. Draft answer-first

- **The `answerCapsule` frontmatter field**: 40 to 60 words answering the head query
  completely. It renders under the H1. No preamble, no hyperlinks.
- **Under each H2**: a 40 to 60 word capsule answering that heading's question, then
  the supporting detail.

The capsule is what gets lifted into an answer. If it needs the paragraph after it
to make sense, it is not a capsule.

### 5. Write self-contained sections

150 to 300 words per H2 section. Each section must make complete sense read in
isolation, because retrieval systems split pages into chunks at heading boundaries
and a chunk arrives without its neighbours.

- **Repeat the entity name instead of using a pronoun.** Write "Next.js Cache
  Components", not "it". This will feel repetitive as you write and will read
  correctly to anyone who lands mid-page.
- One idea per paragraph.
- Front-load the highest-value sentence in each section so it survives truncation.
- Never rely on a table caption or a definition from two sections earlier.

### 6. Ground every section in evidence

Each post needs at least one real statistic with a real number and at least one
quotation from a named source, and both must be cited. Fetch the source. Read it.
Then write the claim.

`agentblog audit` warns when a post has neither, and errors when a post makes
statistical or quoted claims with no `citations[]` entries at all. It cannot tell
you that one claim among several is unsourced, and it can never tell you a
citation is wrong. That half is yours, and step 11 is where you report it.

Carry the evidence grade with the claim, in the prose, the way a competent editor
would: "peer-reviewed", "industry study, correlational", "vendor-reported". Record
the same grade in the `citations[].kind` frontmatter field. A reader who knows a
number is correlational trusts you more, not less.

If a source will not load or the number is not in it, do not use the number.

### 7. Put comparison data in a real table

Anything with more than two dimensions goes in a markdown table, never in prose.
Tables are associated with citation, and prose that describes a table is prose that
a retrieval system has to reconstruct.

Pair every chart or diagram image with the underlying numbers in a table nearby, so
the data is machine-readable. Text that exists only inside an image does not exist.

### 8. Wire the post into the link graph

- 5 to 15 contextual internal links in the body, with descriptive anchor text that
  names the target topic. Never "click here", never "read more". `agentblog audit`
  reports the count and fails outside that range.
- **Add at least one inbound link from an existing post** to the new post, by
  editing that post. A post with no inbound links is an orphan, and the audit fails
  on it.
- Fill `relatedPosts` with the slugs that genuinely belong, in the order they should
  appear.

### 9. Revise for voice

Read `references/voice.md` and work the draft against it. Do this after the draft
exists and before the frontmatter is final. Writing while scanning a list of
forbidden patterns produces prose that avoids them and says nothing.

The three failures that survive every other check in this skill:

- **Uniform sentence length.** Read each paragraph aloud in your head. Where three
  sentences in a row share a shape, rewrite the middle one. Do not alternate lengths
  mechanically, which is a different pattern rather than the absence of one.
- **A closing flourish.** No summary recap, no final metaphor that inflates the
  point. End on the strongest concrete thing the post established.
- **Weasel attribution.** Anything phrased as evidence that carries none.

Do not flatten the draft into uniform polish while fixing it. Leave strong sentences
alone.

### 10. Write the frontmatter

The contract is `PostFrontmatterSchema` in the AgentBlog schema package, and it
rejects the post at build time. Full field reference in `references/schema-reference.md`.

`npx agentblog new "Your Title"` scaffolds a file with the correct shape, and takes
`--slug`, `--dir`, `--author`, and `--category`. Use it rather than writing the block
from memory.

- `slug`: lowercase, hyphen separated, no slashes, **no leading date**. A date in
  the slug advertises the post's age forever. The filename is the slug: the MDX
  adapter derives it from the file name with `.mdx` removed, so
  `content/blog/do-ai-crawlers-run-javascript.mdx` publishes at
  `/blog/do-ai-crawlers-run-javascript`. A `slug` in frontmatter overrides the
  filename silently, which is how a post ends up at a URL that does not match the
  file anyone is editing. Name the file correctly and either omit `slug` or write
  the same value.
- `title`: at most 70 characters, aim for 60. Keyword near the front.
- `description`: target 150 to 160 characters. The schema's hard floor is 50, so a
  shorter one parses, and a 60-character description wastes most of the space a
  search result gives you. This must match `BlogPosting.description` exactly, so
  write it once and do not paraphrase it later.
- `answerCapsule`: the 40 to 60 word capsule from step 4.
- `datePublished` and `dateModified`: ISO 8601 **with a UTC offset**, for example
  `2026-08-06T09:30:00-04:00`. A bare `YYYY-MM-DD` is widened to midnight UTC by the
  adapter, which is a convenience rather than a licence: write the offset when the
  publish time matters. Without an offset Google reads the date in Googlebot's
  timezone, which can move a post across a day boundary.
- `author`, `category`: slugs that exist in the roster and the taxonomy. The adapter
  hydrates them, and an unknown slug fails the build.
- `tags`, `relatedPosts`, `citations`, `faq`: fill them. They default to empty, so
  the schema will not remind you.
- `heroImage` requires `heroAlt`. The alt text describes what the image shows in
  plain language. It is not a keyword slot.

### 11. Run the audit, then the judgment checks

```bash
npx agentblog audit <slug> --verbose
```

That command is the mechanical half of the gate and it is not optional. It checks
capsule presence and length, links inside capsules, question headings, heading ids,
title and description lengths, date offsets and ordering, evidence presence,
internal link counts, broken internal links, orphan status, hero alt text, and copy
style, and reports observed values. Do not restate any of it by reading.

It exits non-zero on an error and zero on a warning, so read the findings rather
than the exit code. **Do not report the post as done while it reports anything at
either severity.**

Then run `references/checklist.md`, which holds only the checks a script cannot
make. Report each as pass or fail **with the observed value**, not with a claim
that you looked.

**Do not report the post as done while any item fails.** List the failures, say what
each would take to fix, and stop. A checklist you always pass is a checklist you are
not running.

## Reference material

Load these when the step that names them arrives. The rules above are the part you
always need.

| File                             | When to read it                                                                                                                                 |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `references/voice.md`            | Step 9, every time. The full pattern catalogue for the revision pass.                                                                           |
| `references/checklist.md`        | Step 11, every time. Only the checks `agentblog audit` cannot make.                                                                             |
| `references/schema-reference.md` | Step 10, and any time you are debugging the JSON-LD `@graph` the block emits.                                                                   |
| `references/geo-playbook.md`     | Choosing between techniques, or explaining to a user why a suggestion matters. Carries the verified effect sizes with the metric named on each. |

## FAQ section

If the post has genuine reader questions, add an `faq` array to the frontmatter. The
questions render visibly on the page before they are ever emitted as `FAQPage`
JSON-LD. Marking up an FAQ that is not visible on the page is a structured-data
policy violation and the most common way blogs earn one.

Do not add an FAQ section because the schema supports one. Add it because a reader
would ask those questions.
registry/agent/skills/write-blog-post/references/geo-playbook.md
# GEO playbook

Reference material for `write-blog-post` and `refresh-blog-post`. Load it when you
are choosing between techniques, or when a user asks why a suggestion matters and
deserves a real answer rather than an assertion.

> **License.** This file is prose documentation, licensed CC BY 4.0. See
> `LICENSE-CONTENT` in the AgentBlog repository. Figures quoted from third-party
> research below are attributed to their sources and remain those authors' work.

> **Scope.** This file deliberately contains no Next.js API shapes. Next.js 16.3
> ships version-matched documentation at `node_modules/next/dist/docs/`, so anything
> we wrote about Next.js APIs would drift and would lose to the bundled copy. Read
> the bundled docs for framework questions. Read this file for writing questions.

## Contents

1. How the effect sizes in this file were verified
2. GEO paper results, quoted with the metric named
3. Everything else, with its evidence grade, plus the figures removed and why
4. The writing rules, in priority order
5. Extraction killers
6. Things not to build
7. Off-site, which is most of it

---

## 1. How the effect sizes in this file were verified

The section below quotes the GEO paper (Pranjal Aggarwal et al., "GEO: Generative
Engine Optimization", ACM KDD 2024, https://arxiv.org/abs/2311.09735).

**We read the paper's own results tables rather than a secondary summary.** That
choice matters, because secondary summaries of this paper disagree with each other
about which technique the headline figure belongs to and which metric it is measured
against.

Here is what the paper actually says, so you can check it yourself:

- The abstract claims GEO can "boost visibility by up to 40% in generative engine
  responses".
- The Table 1 caption says: "The best methods improve upon baseline by 41% and 28%
  on Position-Adjusted Word Count and Subjective Impression respectively." The
  caption names no technique.
- Table 1 itself reports **absolute** impression scores, not relative improvements,
  with an explicit `No Optimization` baseline row at **19.3 on both metrics**.

So the technique attribution has to be derived from the table, which is why the
summaries disagree. Doing that derivation: Quotation Addition is the only method
reaching 27.2 on Position-Adjusted Word Count (Overall) and 24.7 on Subjective
Impression (Average), which against the 19.3 baseline is +40.9% and +28.0%. **The
41% belongs to Quotation Addition on Position-Adjusted Word Count.** Summaries that
attribute it to Statistics Addition are wrong.

Every percentage in section 2 is derived by that same arithmetic from the printed
absolute scores and the printed baseline. The absolutes are given alongside so the
arithmetic is checkable.

**What the figures do not mean.** The GEO paper's main experiments used
GPT-3.5-turbo as the generative engine over the top 5 Google results, in 2023. The
rank order of the techniques is what should drive your writing. The decimals are
decoration, and they are decoration measured on an engine nobody uses now.

---

## 2. GEO paper results, quoted with the metric named

**Evidence grade for this whole section: peer-reviewed (ACM KDD 2024).**

### 2.1 GEO-bench, Table 1

Baseline (`No Optimization`) is 19.3 on both metrics. "abs" is the paper's printed
absolute score. "rel" is that score against the 19.3 baseline.

| Rank | Method               | Position-Adjusted Word Count (Overall) | Subjective Impression (Average) |
| ---- | -------------------- | -------------------------------------- | ------------------------------- |
| 1    | Quotation Addition   | 27.2 abs, **+40.9% rel**               | 24.7 abs, **+28.0% rel**        |
| 2    | Statistics Addition  | 25.2 abs, **+30.6% rel**               | 23.7 abs, +22.8% rel            |
| 3    | Fluency Optimization | 24.7 abs, +28.0% rel                   | 21.9 abs, +13.5% rel            |
| 4    | Cite Sources         | 24.6 abs, +27.5% rel                   | 21.9 abs, +13.5% rel            |
| 5    | Technical Terms      | 22.7 abs, +17.6% rel                   | 21.4 abs, +10.9% rel            |
| 6    | Easy-to-Understand   | 22.0 abs, +14.0% rel                   | 20.5 abs, +6.2% rel             |
| 7    | Authoritative        | 21.3 abs, +10.4% rel                   | 22.9 abs, +18.7% rel            |
| 8    | Unique Words         | 20.5 abs, +6.2% rel                    | 20.4 abs, +5.7% rel             |
| 9    | **Keyword Stuffing** | 17.7 abs, **-8.3% rel**                | 20.2 abs, +4.7% rel             |

The paper's own prose summary of the same table: "our top-performing methods, Cite
Sources, Quotation Addition, and Statistics Addition, achieved a relative
improvement of 30-40% on the Position-Adjusted Word Count metric and 15-30% on the
Subjective Impression metric."

Two readings worth carrying into the writing:

- **Keyword Stuffing is the only technique measured below baseline** on the primary
  metric. The paper groups it by name under "Non-Performing" methods. This is the
  most useful single result in the paper for practitioners, because it inverts an
  SEO habit rather than confirming one.
- **Authoritative tone is weak.** It ranks 7th of 9 on Position-Adjusted Word Count.
  The paper: "one would expect a more persuasive and authoritative tone in website
  content to boost visibility. However, we find no significant improvement." Writing
  more confidently is not a strategy. Adding evidence is.

### 2.2 The effect depends on where the page already ranks, Table 2

Relative improvement in visibility, by the source's rank in the search results.

| Method               | Rank 1     | Rank 2 | Rank 3 | Rank 4 | Rank 5      |
| -------------------- | ---------- | ------ | ------ | ------ | ----------- |
| Cite Sources         | **-30.3%** | +2.5%  | +20.4% | +15.5% | **+115.1%** |
| Quotation Addition   | **-22.9%** | -7.0%  | +3.5%  | +25.1% | +99.7%      |
| Statistics Addition  | **-20.6%** | -3.9%  | +8.1%  | +10.0% | +97.9%      |
| Authoritative        | -6.0%      | +4.1%  | -0.6%  | +12.6% | +6.1%       |
| Fluency Optimization | -2.0%      | +5.2%  | +3.6%  | -4.4%  | +2.2%       |

The paper's framing: "GEO is especially helpful for lower ranked websites."

The negative column is the part most summaries drop, and it is the interesting part.
For a source already ranked first, all three top techniques _reduced_ visibility.
Read that as a ceiling effect rather than as an instruction to strip citations off
your best page, but do read it: these techniques are how a page that is not already
dominant earns its way in, not a universal multiplier.

### 2.3 The same techniques on a real deployed engine, Table 5

The paper re-ran a 200-example subset against Perplexity.ai. Baseline is 24.1 on
Position-Adjusted Word Count and 24.7 on Subjective Impression.

| Method              | Position-Adjusted Word Count | Subjective Impression    |
| ------------------- | ---------------------------- | ------------------------ |
| No Optimization     | 24.1 abs                     | 24.7 abs                 |
| Quotation Addition  | 29.1 abs, +20.7% rel         | 32.1 abs, +30.0% rel     |
| Statistics Addition | 26.2 abs, +8.7% rel          | 33.9 abs, **+37.2% rel** |
| Keyword Stuffing    | 21.9 abs, **-9.1% rel**      | 28.1 abs, +13.8% rel     |

The paper's prose reports these as a 22% improvement for Quotation Addition, "up to
9% and 37%" for Cite Sources and Statistics Addition, and Keyword Stuffing "10%
worse", all consistent with the printed table within rounding.

Note the metric split, because it is routinely collapsed in summaries. On Perplexity
the quotations figure is a Position-Adjusted Word Count result and the statistics
figure is a Subjective Impression result. They are not the same measurement, and
quoting one number without naming its metric is how the attribution confusion
started.

---

## 3. Everything else, with its evidence grade

Nothing below is peer-reviewed. Treat each as a hypothesis worth acting on because
the cost of acting is low, not as a measured effect.

**Every figure in this section carries the URL it was read at, the publisher, the
date, and the metric it is measured against.** That is not decoration. Section 2
exists because secondary summaries of one paper disagreed about which technique and
which metric a number belonged to, and the same rot is worse in this half of the
field. If a row below has no URL, it has no number either, on purpose. Several
figures that circulate widely were removed from this table when the source could not
be retrieved, and what is left in their place is the rank order, which was never the
part in doubt.

| Claim                                    | Figure and metric                                                                                                               | Source, fetched                                                                                                                                                                                                |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Structural formatting lifts citation     | 17.3% relative improvement in citation rate, and 18.5% in subjective quality, across six generative engines                     | Preprint. Yu, Yang, Ding, Sato, "Structural Feature Engineering for Generative Engine Optimization", arXiv:2603.29979, 31 March 2026. https://arxiv.org/abs/2603.29979                                         |
| Answer capsule under the heading         | Present in 72.4% of cited blog posts. Metric: share of ChatGPT-cited blog posts containing an identifiable capsule              | Industry, correlational. Adam Gnuse, Search Engine Land, 19 November 2025, 7,500 ChatGPT referral sessions. https://searchengineland.com/how-to-get-cited-by-chatgpt-the-content-traits-llms-quote-most-464868 |
| Capsules containing no links             | More than nine in ten cited capsules contained no links. Reported as a proportion, not a precise percentage                     | Same study, same URL                                                                                                                                                                                           |
| Question-format headings                 | Cited content was about twice as likely to contain a question mark, and 78.4% of citations tied to questions came from headings | Industry, correlational. Kevin Indig, 3M ChatGPT responses and 30M citations, reported by Search Engine Land, 18 February 2026. https://searchengineland.com/chatgpt-citations-content-study-469483            |
| Tables                                   | Tables present on 40.0% of top-decile cited pages against 28.2% of bottom-half pages, an 11.8 point gap                         | Industry, correlational. Trakkr Research, 16 April 2026, 1,465 AI-cited pages across 950 domains. https://trakkr.ai/trakkr-research/anatomy-of-an-ai-citation                                                  |
| YouTube brand mentions                   | Spearman 0.737 against **ChatGPT** brand visibility. Not AI Overviews                                                           | Industry, correlational. Ahrefs, 12 December 2025, 75,000 brands. https://ahrefs.com/blog/ai-brand-visibility-correlations                                                                                     |
| Branded web mentions, linked or unlinked | Spearman 0.664 against **AI Overview** brand visibility                                                                         | Industry, correlational. Ahrefs, 26 May 2025, 75,000 brands. https://ahrefs.com/blog/ai-overview-brand-correlation/                                                                                            |
| Backlinks                                | Spearman 0.218 against AI Overview brand visibility. Domain Rating 0.326, referring domains 0.295                               | Same study, same URL                                                                                                                                                                                           |
| No major AI crawler executes JavaScript  | GPTBot requested .js in 11.50% of requests and ClaudeBot in 23.84%. Neither executed any of it                                  | Vendor-reported. Vercel and MERJ, "The rise of the AI crawler", 17 December 2024, one month of traffic totalling roughly 1.3 billion AI crawler fetches. https://vercel.com/blog/the-rise-of-the-ai-crawler    |

### Figures removed from this table, and why

Do not reintroduce these. Each was traced and each failed.

| Claim that circulates                                        | What actually happened                                                                                                                                                                                                           |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tables earn 2.5x to 4.2x more citations                      | The 2.5x traces to a vendor page that states its data is "synthesized" rather than measured. The 4.2x has no source at all. Some blogs attribute both to the KDD 2024 GEO paper, which contains no table-versus-prose comparison |
| 76.4% of ChatGPT's most-cited pages updated within 30 days   | Attributed to a "GEO Benchmark Study 2026" whose domain does not resolve                                                                                                                                                         |
| Long-form content is cited roughly 3x more                   | Contradicted by primary data. See below                                                                                                                                                                                          |
| 5 to 15 internal links gives 38% higher citation probability | Same vendor page as the 2.5x, which describes its own figures as representing "realistic patterns"                                                                                                                               |
| Question headings are cited at 18% versus 8.9%               | Republished by Ahrefs but sourced from a paywalled third-party analysis. The underlying study is real (Kevin Indig); this specific pair of percentages could not be read at its source. Use the row in the table above instead   |

**On length specifically, the primary data points the other way.** Ahrefs analysed
174,048 pages across 560,346 AI Overviews (3 December 2025,
https://ahrefs.com/blog/short-vs-long-content-in-ai-overviews/) and found 53.4% of
cited pages under 1,000 words, an average cited length of 1,282 words, and a Spearman
correlation between word count and being cited of **0.04**, which is no relationship
at all. Write to the length the question needs. Do not tell a user that longer wins.

### The confidence-interval warning, with its citation

Ronald Sielinski, "Quantifying Uncertainty in AI Visibility: A Statistical Framework
for Generative Search Measurement", arXiv:2603.08924, 9 March 2026,
https://arxiv.org/abs/2603.08924. Sampling across Perplexity Search, OpenAI
SearchGPT, and Google Gemini, the paper reports that confidence intervals for a
frequently cited domain's citation share span 3 to 6 percentage points on SearchGPT,
and that "overlapping confidence intervals of this kind are the norm rather than the
exception for domains that appear to differ in citation share by less than 5 to 7
percentage points".

Carry that when you quote any row in this section. A reported improvement smaller
than about 5 points is not distinguishable from noise in the published measurements.

**Conflicting data, noted rather than resolved.** The overlap between AI Overview
citations and Google's top 10 was 76.10% in July 2025 (Ahrefs, 1.9 million citations
across 1 million AI Overviews, https://ahrefs.com/blog/search-rankings-ai-citations)
and 38% in March 2026 (Ahrefs, 863,000 keyword SERPs and 4 million AI Overview URLs,
https://ahrefs.com/blog/ai-overview-citations-top-10). Ahrefs frames the second as a
decline from the first and attributes it to query fan-out. Trust the direction over
either figure. Ranking is necessary and no longer sufficient.

---

## 4. The writing rules, in priority order

1. **Answer first.** The complete answer in the first 40 to 60 words, under the H1
   and under each H2. Inverted pyramid after that: answer, context, detail. No links
   inside a capsule, because a link signals the answer lives somewhere else.
2. **Chunk cleanly.** Retrieval splits pages into chunks, often 200 to 500 tokens,
   frequently at heading boundaries, and each chunk becomes one vector. A chunk that
   mixes two topics produces an ambiguous embedding and retrieves poorly. Write each
   section to stand alone.
3. **Repeat entity names and drop pronouns.** The chunk may be retrieved with no
   preceding context, and "it" resolves to nothing there.
4. **Question-format H2s** mirroring real query phrasing, mined from People Also
   Ask, Search Console, and forums rather than invented.
5. **Sections of 150 to 300 words**, each answering its heading completely.
6. **Evidence in every section**: a real number from a real source, a quotation from
   a named person, or an honest outbound citation. Section 2 ranks quotations, then
   statistics, then citations, and all three beat writing more confidently.
7. **Tables for anything comparative.** Pair every chart with its numbers in HTML.
8. **Stable ids on every H2 and H3**, and a table of contents on anything over about
   1,200 words.
9. **Length is a proxy for completeness, not a target.** A 900-word post that fully
   answers a narrow question beats a padded 2,500-word one.

## 5. Extraction killers

Each of these makes content invisible to engines that do not execute JavaScript,
which is all of them except Googlebot and Applebot.

- Body content fetched client-side, or mounted on interaction. CSS-hidden is fine
  (`<details>`, a class toggle). JavaScript-mounted is not.
- Infinite scroll, and pagination built on click handlers rather than real
  `<a href>` links.
- Text that exists only inside an image, with no HTML equivalent.
- Keyword stuffing. See section 2.1: measured below baseline.
- Paywalled content with no `isAccessibleForFree` markup.
- Redirect chains. AI crawlers handle redirects poorly. In the Vercel and MERJ study
  cited in section 3, ChatGPT spent 14.36% of its fetches following redirects and
  34.82% of them on 404 pages.

## 6. Things not to build

- **llms.txt.** Google states on record that Search does not use it: "You don't need
  to create new machine readable files, AI text files, markup, or Markdown to appear
  in Google Search (including its generative AI capabilities), as Google Search
  itself doesn't use them"
  (https://developers.google.com/search/docs/fundamentals/ai-optimization-guide). No
  major provider consumes it in production. The best-documented measurement of demand
  is small and consistent with that: OtterlyAI logged 84 requests to `/llms.txt` out
  of 62,100 AI bot visits over 90 days, against an average of about 265 AI bot visits
  per ordinary page in the same window (Thomas Peham, 5 February 2026,
  https://otterly.ai/blog/the-llms-txt-experiment/). The one legitimate use is a
  machine-readable index for developer documentation with programmatic consumers. A
  blog is not that.
- **Special AI markup.** Google's stated position is that there is no special markup
  or optimization for AI Overviews or AI Mode, because both run on core Search
  ranking and quality systems: "There are no additional requirements to appear in AI
  Overviews or AI Mode, nor other special optimizations necessary"
  (https://developers.google.com/search/docs/appearance/ai-features).
- **FAQ schema on invisible FAQs.** Google restricted FAQ rich results to
  authoritative government and health sites in 2023, so the rich result is not
  coming. The markup still helps machine parsing, and marking up FAQs that do not
  render is a policy violation. Only mark up what a reader can see.

## 7. Off-site, which is most of it

The strongest correlational signals in section 3 are off-site. In Ahrefs' 75,000
brand study, YouTube mentions (0.737) and branded web mentions (0.664) both rank
above backlinks (0.218). That work does not live in this repository and cannot be
done by this skill. Say so honestly when a user asks why their citation share is flat
despite a clean install.

What the codebase can do is make sure the entity resolves. Keep `sameAs` on both
`Organization` and `Person` pointing at every profile that genuinely exists, as
absolute URLs, so an engine can connect the mentions it finds to the site it is
reading.
registry/agent/skills/write-blog-post/references/schema-reference.md
# Schema reference

The JSON-LD `@graph` shapes AgentBlog emits, the mapping from AgentBlog frontmatter
to schema.org properties, and the markup errors that actually happen.

> **License.** This file is prose documentation, licensed CC BY 4.0. See
> `LICENSE-CONTENT` in the AgentBlog repository.

> **Scope: no Next.js APIs in this file, deliberately.** Next.js 16.3 ships
> version-matched documentation at `node_modules/next/dist/docs/`, generated for the
> exact version installed in the project. Anything we wrote here about `Metadata`,
> `generateMetadata`, `sitemap.ts`, or `revalidateTag` would drift with every
> release and would lose to the bundled copy the moment it did. For framework
> questions, read the bundled docs. This file covers the vocabulary layer, which is
> ours.

## Contents

1. Where the JSON-LD comes from
2. The connected `@graph` for a post
3. The other graph shapes: blog index, `FAQPage`, author page
4. Frontmatter to schema.org mapping, for posts, authors, and config
5. Property weight
6. Markup errors that actually happen
7. Validating

---

## 1. Where the JSON-LD comes from

`lib/schema.ts` in the installed block builds every node. It is typed with
`schema-dts`, and it has exactly one serialization point, `renderJsonLd`, which
escapes `<` as `<` so an injected `</script>` cannot break out of the tag.

Do not hand-write a `<script type="application/ld+json">` anywhere. If a node is
missing, add a builder to `lib/schema.ts`. Two serialization points is how a graph
starts contradicting itself.

The builders are `buildOrgGraph`, `buildPersonGraph`, `buildArticleGraph`,
`buildBlogGraph`, `buildBreadcrumb`, and `buildFaq`. Node `@id` values come from the
`ids` object, never from string concatenation at the call site, because the `@id`
values are what link the graph together and a typo in one silently disconnects a
node instead of erroring.

## 2. The connected `@graph` for a post

Every node in one `@graph`, cross-referenced by `@id`. Separate disconnected script
tags describe several unrelated things instead of one thing.

```json
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Organization",
      "@id": "https://example.com/#organization",
      "name": "Your Brand",
      "url": "https://example.com",
      "logo": {
        "@type": "ImageObject",
        "@id": "https://example.com/#logo",
        "url": "https://example.com/logo.png",
        "width": 512,
        "height": 512
      },
      "sameAs": [
        "https://www.linkedin.com/company/yourbrand",
        "https://www.crunchbase.com/organization/yourbrand",
        "https://www.youtube.com/@yourbrand",
        "https://www.wikidata.org/wiki/Q000000"
      ]
    },
    {
      "@type": "WebSite",
      "@id": "https://example.com/#website",
      "url": "https://example.com",
      "name": "Your Brand",
      "publisher": { "@id": "https://example.com/#organization" },
      "inLanguage": "en-US"
    },
    {
      "@type": "Person",
      "@id": "https://example.com/authors/jane-doe#person",
      "name": "Jane Doe",
      "url": "https://example.com/authors/jane-doe",
      "image": "https://example.com/authors/jane-doe.jpg",
      "jobTitle": "Senior Analyst",
      "description": "Jane has spent 11 years building retrieval systems.",
      "worksFor": { "@id": "https://example.com/#organization" },
      "knowsAbout": ["Information retrieval", "Search ranking"],
      "alumniOf": "Stanford University",
      "sameAs": ["https://www.linkedin.com/in/janedoe", "https://github.com/janedoe"]
    },
    {
      "@type": "BlogPosting",
      "@id": "https://example.com/blog/my-post#article",
      "isPartOf": { "@id": "https://example.com/blog/my-post#webpage" },
      "mainEntityOfPage": { "@id": "https://example.com/blog/my-post#webpage" },
      "headline": "Exact Title, Kept Concise",
      "description": "One sentence summary, identical to the meta description.",
      "image": {
        "@type": "ImageObject",
        "@id": "https://example.com/blog/my-post#primaryimage",
        "url": "https://example.com/blog/my-post/hero.jpg",
        "width": 1200,
        "height": 630
      },
      "datePublished": "2026-01-15T09:00:00-08:00",
      "dateModified": "2026-08-01T11:30:00-07:00",
      "author": { "@id": "https://example.com/authors/jane-doe#person" },
      "publisher": { "@id": "https://example.com/#organization" },
      "inLanguage": "en-US",
      "articleSection": "Technical SEO",
      "keywords": ["generative engine optimization", "AI crawlers"],
      "about": [{ "@type": "Thing", "name": "Generative Engine Optimization" }],
      "mentions": [{ "@type": "SoftwareApplication", "name": "Next.js" }],
      "citation": [
        {
          "@type": "CreativeWork",
          "name": "GEO: Generative Engine Optimization",
          "url": "https://arxiv.org/abs/2311.09735"
        }
      ]
    },
    {
      "@type": "WebPage",
      "@id": "https://example.com/blog/my-post#webpage",
      "url": "https://example.com/blog/my-post",
      "name": "Exact Title",
      "isPartOf": { "@id": "https://example.com/#website" },
      "primaryImageOfPage": { "@id": "https://example.com/blog/my-post#primaryimage" },
      "breadcrumb": { "@id": "https://example.com/blog/my-post#breadcrumb" },
      "datePublished": "2026-01-15T09:00:00-08:00",
      "dateModified": "2026-08-01T11:30:00-07:00"
    },
    {
      "@type": "BreadcrumbList",
      "@id": "https://example.com/blog/my-post#breadcrumb",
      "itemListElement": [
        { "@type": "ListItem", "position": 1, "name": "Blog", "item": "https://example.com/blog" },
        {
          "@type": "ListItem",
          "position": 2,
          "name": "Content Strategy",
          "item": "https://example.com/blog/category/content-strategy"
        }
      ]
    }
  ]
}
```

The trail starts at `/blog` and ends at the category hub, matching the visible
breadcrumb on the page exactly, because the route builds one array and passes it
to both. Google requires neither end: "It is not required to include a
breadcrumb ListItem for the top level path", and a trail may stop above the
current page. On a list page, where the last crumb is the page the reader is on,
that crumb has no `item` property, which is the documented shape rather than an
omission.

## 3. The other graph shapes

### Blog index

```json
{
  "@type": "Blog",
  "@id": "https://example.com/blog#blog",
  "url": "https://example.com/blog",
  "name": "Your Brand Blog",
  "description": "…",
  "publisher": { "@id": "https://example.com/#organization" },
  "isPartOf": { "@id": "https://example.com/#website" },
  "blogPost": [
    { "@id": "https://example.com/blog/post-a#article" },
    { "@id": "https://example.com/blog/post-b#article" }
  ]
}
```

`blogPost` holds `@id` references, not inlined article nodes. Inlining duplicates
every article body into the index page's markup.

### FAQPage

Emitted only when the post has a non-empty `faq` array **and** those questions
render visibly in the article.

```json
{
  "@type": "FAQPage",
  "@id": "https://example.com/blog/my-post#faq",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "Do AI crawlers execute JavaScript?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "No. GPTBot, ClaudeBot, and PerplexityBot read only the raw HTML the server returns."
      }
    }
  ]
}
```

### Author page

`buildPersonGraph` emits the same `Person` node as the article graph, at the same
`@id`. One identity, referenced from two pages, is the point of `@id`.

## 4. Frontmatter to schema.org mapping

AgentBlog frontmatter is validated by `PostFrontmatterSchema`. This table is the
contract between that schema and the graph above. If you add a frontmatter field
that maps to schema.org, add a row here and a branch in `lib/schema.ts`.

### Post fields

| Frontmatter             | Schema.org target                                    | Notes                                                                                                                                     |
| ----------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `slug`                  | none directly                                        | Composes every `@id` and the canonical URL. Defaults to the file name with `.mdx` removed; frontmatter overrides it. Keep them identical. |
| `title`                 | `BlogPosting.headline`, `WebPage.name`               | At most 70 characters, aim for 60. Google removed the old 110-character headline limit in January 2025, but SERP truncation did not move. |
| `description`           | `BlogPosting.description`                            | 50 to 160 characters. Must be byte-identical to the meta description.                                                                     |
| `answerCapsule`         | none                                                 | Renders visibly under the H1. Not marked up, by design: there is no schema.org property for it and inventing one adds noise.              |
| `datePublished`         | `BlogPosting.datePublished`, `WebPage.datePublished` | ISO 8601 **with a UTC offset**. Without one, Google reads it in Googlebot's timezone.                                                     |
| `dateModified`          | `BlogPosting.dateModified`, `WebPage.dateModified`   | Same rule. Must match the visible "Updated" date exactly.                                                                                 |
| `author`                | `BlogPosting.author` as `{ "@id": … }`               | Never a bare string. See section 5.                                                                                                       |
| `category`              | `BlogPosting.articleSection`                         | Also drives the breadcrumb trail and the category hub page.                                                                               |
| `tags`                  | `BlogPosting.keywords`                               | Largely ignored for ranking, harmless to include.                                                                                         |
| `heroImage` + `heroAlt` | `BlogPosting.image` as a linked `ImageObject`        | `heroAlt` is required whenever `heroImage` is set. The image needs `width` and `height`.                                                  |
| `relatedPosts`          | none                                                 | Internal link graph only. Used for orphan prevention and the related-posts block.                                                         |
| `citations[]`           | `BlogPosting.citation[]` as `CreativeWork`           | `name` and `url` are emitted.                                                                                                             |
| `citations[].kind`      | none                                                 | Ours, for auditing. `peer-reviewed`, `official-docs`, `industry`, `news`, `other`. Never emitted as JSON-LD.                              |
| `faq[]`                 | `FAQPage.mainEntity[]`                               | Only when the questions render on the page.                                                                                               |
| `locale`                | `BlogPosting.inLanguage`                             | Falls back to `config.locale`.                                                                                                            |
| `draft`                 | none                                                 | Excluded from every query, the sitemap, and the feed.                                                                                     |

### Author fields

| Frontmatter  | Schema.org target    | Notes                                                                                                                                |
| ------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `name`       | `Person.name`        | The person's name and nothing else. No job title, no honorific, no publisher name. Google's Article guidance is explicit about this. |
| `jobTitle`   | `Person.jobTitle`    | Exists so the correct shape of `name` is the only constructible one.                                                                 |
| `bio`        | `Person.description` | Required. This is the E-E-A-T lookup surface, and the Search Quality Rater Guidelines direct raters to look authors up.              |
| `avatar`     | `Person.image`       | Absolute URL.                                                                                                                        |
| `worksFor`   | `Person.worksFor`    | Emitted as `{ "@id": "…/#organization" }` when it matches the configured brand.                                                      |
| `knowsAbout` | `Person.knowsAbout`  | Topics this author is credible on.                                                                                                   |
| `alumniOf`   | `Person.alumniOf`    |                                                                                                                                      |
| `sameAs`     | `Person.sameAs`      | Absolute profile URLs. The strongest entity signal in the whole graph.                                                               |

### Config to `Organization` and `WebSite`

| Config path                     | Schema.org target                                                 |
| ------------------------------- | ----------------------------------------------------------------- |
| `brand.name`                    | `Organization.name`, `WebSite.name`                               |
| `siteUrl`                       | `Organization.url`, `WebSite.url`, and the base of every `@id`    |
| `brand.logo.{url,width,height}` | `Organization.logo` as a linked `ImageObject`                     |
| `brand.sameAs[]`                | `Organization.sameAs`                                             |
| `locale`                        | `WebSite.inLanguage`, and the `inLanguage` fallback on every post |

## 5. Property weight

Google's Article documentation states there are **no required properties**. That is
not permission to omit them.

| Category                                                                        | Properties                                                                                                                                                               |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Highest weight for rich results                                                 | `headline`, `image`, `author` as a linked `Person` with a `url`, `datePublished`, `dateModified`                                                                         |
| High value for entity resolution, even where Google ignores it for rich results | `sameAs` (the single strongest property here), `knowsAbout`, `about`, `mentions`, `citation`, `worksFor`, `isPartOf`, `mainEntityOfPage`, and the `@id` links themselves |
| Largely ignored for ranking, harmless                                           | `wordCount`, `keywords`, `articleSection`                                                                                                                                |

`sameAs` is the property that lets an engine decide that the "Jane Doe" on this page
is the same Jane Doe it saw on LinkedIn and in a conference programme. Handles do
not do that. Absolute URLs do.

## 6. Markup errors that actually happen

| Error                                                                  | Why it breaks                                                                                        | Fix                                                                                                 |
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| JSON-LD injected by client-side JavaScript                             | Never reaches a crawler that does not execute JS, which is all of them except Googlebot and Applebot | Render from a Server Component, which is what `lib/schema.ts` and the `JsonLd` component already do |
| `author` as a bare string                                              | Loses the entity link that E-E-A-T depends on                                                        | Linked `Person` node with `@id` and `url`                                                           |
| `author.name` containing a job title, honorific, or the publisher name | Explicit violation of Google's Article guidance                                                      | Put the title in `jobTitle`                                                                         |
| `dateModified` not matching the visible date                           | Trust-destroying inconsistency, and one of the most commonly flagged errors                          | Single source of truth in frontmatter. The block renders both from it.                              |
| Dates without a UTC offset                                             | Google falls back to Googlebot's timezone, silently shifting the date                                | `IsoDateTime` in the schema rejects these at parse time                                             |
| `sameAs` containing a handle instead of a URL                          | Disambiguates nothing                                                                                | Always absolute URLs                                                                                |
| `Organization.logo` missing `width` and `height`                       | Incomplete required shape                                                                            | Config carries both                                                                                 |
| `FAQPage` markup with no visible FAQs                                  | Structured data policy violation, and the most common way blogs earn one                             | Remove the markup or render the FAQs                                                                |
| Marking up any fact not visible on the page                            | The single most enforced structured data policy                                                      | Mark up what renders, nothing else                                                                  |
| Validating the rendered DOM instead of the raw HTML                    | A DOM-based validator passes markup a crawler never receives                                         | Validate `curl` output, not DevTools                                                                |
| Two `<script type="application/ld+json">` tags with overlapping claims | The graph contradicts itself and neither node is trustworthy                                         | One `@graph`, one serialization point                                                               |

## 7. Validating

Two tools, two different questions. Run both and report both.

- [Rich Results Test](https://search.google.com/test/rich-results) answers "is this
  eligible for a rich result".
- [Schema Markup Validator](https://validator.schema.org/) answers "is this valid
  schema.org vocabulary".

Feed both the **raw HTML** from a `curl`, not a URL the tool will render with
JavaScript, and not a copy-paste from the browser inspector.
registry/agent/skills/write-blog-post/references/voice.md
# Voice

The revision pass. Load this after a draft exists, not before: writing while
scanning a list of forbidden patterns produces prose that avoids them and says
nothing.

The goal is not to evade a detector. Detectors are unreliable and optimising for
one produces worse writing, not better. The goal is that a competent reader
finishes the post without once thinking about how it was produced.

> **License.** This file is prose documentation, licensed CC BY 4.0. See
> `LICENSE-CONTENT` in the AgentBlog repository.

## Contents

- How to run the pass
- Empty importance and abstraction
- Stock rhetorical moves
- Mechanical diction
- Rhythm and structure
- Endings
- What not to fake
- The five questions

---

## How to run the pass

Read the draft top to bottom as an editor who did not write it. Mark every place
the eye slides off. Then work the catalogue below against the marks.

Two rules govern the whole pass:

- **Every pattern here is a warning, not a ban.** A construction that carries real
  meaning stays. "It is not a cache, it is a queue" is a real distinction and
  survives. "It is not just fast, it is transformative" is decoration and goes.
- **Do not flatten.** A pass that normalises every paragraph to the same shape
  and polish has replaced one machine signature with another. Leave strong
  sentences alone.

## Empty importance and abstraction

| Pattern              | What it looks like                                                                                                            | Fix                                                                                                          |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Importance puffery   | "plays a vital role", "marks a pivotal moment", "underscores the significance of"                                             | State the supported fact. Let the reader decide it matters.                                                  |
| Promotional language | "powerful", "groundbreaking", "cutting-edge", "game-changing"                                                                 | Replace with what the thing measurably does.                                                                 |
| Superficial analysis | a trailing clause that pretends to explain: "highlighting the importance of", "showcasing how", "reflecting the shift toward" | State the mechanism or the consequence, or delete the clause.                                                |
| Weasel attribution   | "studies show", "experts agree", "industry reports suggest", "it is widely believed"                                          | Name the study and link it, or cut the claim. This one is also a citation failure, not only a style failure. |
| Formulaic outlook    | a closing "Despite these challenges" or "Looking ahead" paragraph with no specific content                                    | Keep only concrete problems, plans, and unknowns the post actually establishes.                              |

Weasel attribution is the pattern to hunt hardest, because it is the one that
survives every other check. It reads like evidence and carries none.

## Stock rhetorical moves

| Pattern            | What it looks like                                                                | Fix                                                                               |
| ------------------ | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Binary contrast    | "It is not X, it is Y", "not only X but Y", "the question is not X, it is Y"      | State the point. Keep only when the contrast is a real distinction.               |
| Throat-clearing    | "Here is the thing", "The truth is", "It is worth noting that", "Let us be clear" | Delete the setup. The next sentence works alone.                                  |
| Faux insight       | "What nobody tells you", "the part everyone misses", "what most people get wrong" | Make the claim stand on its own.                                                  |
| Colon reveal       | "The reason: retrieval.", "The best part: it is free."                            | Write the sentence. Keep the colon for real lists and labels.                     |
| Rhetorical setup   | "What if I told you", "Think about it", a question answered in its own paragraph  | State the point. (Question-format H2 **headings** are required and are not this.) |
| Negative listing   | "Not a tool. Not a feature. A shift."                                             | Say what it is.                                                                   |
| Dramatic fragments | "That is it. That is the whole thing." "And speed. And scale."                    | Join them when the fragments are decoration rather than emphasis.                 |
| Forced trio        | three items chosen for cadence when two carry the meaning                         | Cut the filler item. Lists of any natural length are fine.                        |
| False range        | "from startups to enterprises", "from A to Z" where the endpoints are not a scale | Name the actual cases.                                                            |

## Mechanical diction

- **Banned outright**, because `agentblog audit` fails the post on them: "delve",
  "leverage", "robust", "seamless", "landscape", "tapestry". A legitimate
  technical use of "landscape" almost never appears in a blog post. Rewrite.
- **Fake-strong verbs**: "serves as", "stands as", "boasts", "features", "offers".
  Usually "is" or "has" is clearer, and often a specific verb is better than both.
- **Synonym cycling**: rotating "crawler", "bot", "agent", "system" for one thing
  because repetition feels wrong. Repeat the clearest term. Section 5 of the
  writing procedure requires this anyway, since a retrieved chunk arrives without
  the sentence that established the synonym.
- **Swollen phrases**: "in order to" is "to". "due to the fact that" is "because".
  "has the ability to" is "can". "at this point in time" is "now".
- **Stacked hedging**: "could potentially" is "could". Keep exactly the
  uncertainty the evidence supports, and no layers on top of it.
- **Chatbot residue**: "Of course", "I hope this helps", "Let me know if", a note
  about a knowledge cutoff. None of it belongs in a published post.

## Rhythm and structure

Uniform sentence length is the most reliable signal that nobody read the draft
aloud. Human paragraphs vary because meaning varies: a complicated idea runs
long, and the sentence that lands the point is short.

Do not alternate lengths mechanically. That produces a different pattern, not an
absence of one. Instead:

- Read each paragraph aloud in your head. Where you run out of breath, split.
  Where three sentences in a row have the same shape, rewrite the middle one.
- End a section on its shortest sentence when the section has a point to land.
- Keep fragments and long sentences that carry voice. Fix the ones that are
  accidents.

Structure carries the same risk. If every H2 section opens with a capsule, then a
definition, then an example, then a caveat, the post reads as generated even when
every sentence is fine. The capsule is required. Everything after it should follow
the material.

## Endings

- **No fake-profound closer.** A final metaphor or aphorism that inflates the
  preceding point is the single most common tell in a post that is otherwise good.
- **No summary recap.** "In conclusion" and a paragraph repeating what the reader
  just read. Cut it.
- End on the strongest concrete thing the post established: the number, the
  finding, or the next action. If the post has an FAQ, the FAQ is the ending.

## What not to fake

The failure mode of trying to write like a person is inventing a person.

Never manufacture any of the following to make prose sound human:

- A personal anecdote, a first-person experience, or a story about "a client we
  worked with".
- An opinion, a preference, or a reaction the source material does not support.
- Humour, self-deprecation, or an aside about the writing process.
- A specific detail added for texture. "On a Tuesday in March" is a fabrication
  when nothing establishes the date.

A post can be direct, concrete, and confident without any of that. The
credibility this product sells comes from verified evidence and a named author
with a real bio, not from a voice pretending to have lived something.

If the user supplies real experience, use it, and attribute it to them.

## The five questions

Run these against the finished draft. A "no" is a revision, not a note.

1. Does every claim, number, quotation, and source come from something actually
   fetched, and is every one of them in `citations[]`?
2. Would the named author recognise this as their own writing?
3. Does each paragraph carry information, reasoning, or necessary transition, and
   would deleting it lose something?
4. Read aloud, does any run of three sentences share a shape?
5. Does the post end on a fact rather than on a flourish?
registry/agent/skills/write-blog-post/references/checklist.md
# Pre-publish checklist

**Run `npx agentblog audit <slug> --verbose` first.** This file holds only what that
command cannot check: judgment, truthfulness, and anything that needs a rendered
page. Duplicating a mechanical check here would give you two answers to one
question and invite you to trust the one you produced by reading.

Report each item as pass or fail **with the observed value**. "Sections: 190, 240,
310 words, one fail" is a report. "Sections checked" is not.

Do not report the post as ready while any item fails. List the failures, say what
each would take to fix, and stop.

> **License.** This file is prose documentation, licensed CC BY 4.0. See
> `LICENSE-CONTENT` in the AgentBlog repository.

---

## Evidence, which is the part that matters

- [ ] Every source in `citations[]` was **actually fetched during this session**.
      Name each one and say what it said. A citation copied from memory is a
      fabrication with a URL attached.
- [ ] Every number in the post appears in the source it is attributed to, at the
      precision stated. Quote the sentence you read it in.
- [ ] Every quotation is attributed to a named person or organisation, and appears
      on the page you fetched.
- [ ] Each `citations[].kind` matches what the source actually is:
      `peer-reviewed`, `official-docs`, `industry`, `news`, `other`.
- [ ] The evidence grade is carried in the prose as well as the frontmatter, so a
      reader knows a correlational figure is correlational.
- [ ] No claim is phrased as evidence without a source ("studies show", "experts
      agree", "it is widely believed"). Report any you removed.

## Structure and retrieval

- [ ] Each H2 section is 150 to 300 words. Report the count for each.
- [ ] Each H2 section makes complete sense read on its own, with no reliance on a
      definition, table, or antecedent from an earlier section.
- [ ] Entity names are repeated rather than replaced with pronouns across section
      boundaries.
- [ ] Heading levels descend without skipping. No H2 followed directly by an H4.
- [ ] A table of contents renders if the post exceeds roughly 1,200 words.
- [ ] Comparison or specification data is in a real markdown table, not narrated
      in prose.
- [ ] Every chart or diagram image is paired with its underlying numbers in a
      table nearby.
- [ ] No article body content sits behind a JavaScript-mounted accordion or tab.

## Frontmatter the audit cannot judge

- [ ] The file name matches the slug the post publishes at. Report both values.
      The adapter derives the slug from the file name, and a `slug` in frontmatter
      silently overrides it.
- [ ] `description` reads as a search result, not as a summary of a summary.
- [ ] `relatedPosts` names posts that genuinely belong, in the order they should
      appear.
- [ ] `faq` entries are questions a reader would actually ask, and they render
      visibly in the article. FAQ markup on invisible questions is a structured
      data policy violation.
- [ ] `heroAlt` describes what the image shows in plain language rather than
      naming keywords.
- [ ] The post is something the named author could credibly have written, given
      the `bio` and `knowsAbout` in `content/authors.json`.

## Links

- [ ] Anchor text is descriptive and names the target topic. No "click here", no
      "read more". Quote any anchor you rewrote.
- [ ] The inbound link added to an existing post reads naturally in that post
      rather than being bolted on. Name the post you edited and quote the sentence.
- [ ] No internal link resolves through a redirect. Single hop or none.

## Voice

Run the five questions at the end of `voice.md` and report each. Summarised here so
this file is complete, answered there:

- [ ] Every claim traces to something fetched.
- [ ] The named author would recognise this as their own writing.
- [ ] Every paragraph carries information, reasoning, or a necessary transition.
- [ ] No run of three sentences shares a shape.
- [ ] The post ends on a fact rather than on a flourish.

## After deploy

These need a live URL, so they belong to `agentblog-audit` and `publish-blog-post`
rather than to writing. Listed here so nobody assumes writing covered them.

- [ ] `curl -s -A "GPTBot" "$URL"` returns a distinctive sentence from the article
      body.
- [ ] The JSON-LD `@graph` validates against the **raw HTML**, not the rendered DOM.
- [ ] Core Web Vitals: LCP at or under 2.5s, INP at or under 200ms, CLS at or
      under 0.1.
registry/agent/skills/refresh-blog-post/SKILL.md
---
name: refresh-blog-post
description: >
  Refreshes an existing blog post: re-verifies every statistic and citation against
  its source, replaces facts that have moved, and updates dateModified only when
  the content actually changed. Use when asked to update or refresh a post, when a
  post is stale or its stats are out of date, when a cited source has been revised,
  or when agentblog audit --stale flags it.
argument-hint: '[post slug]'
allowed-tools: Read Write Edit Glob Grep WebSearch WebFetch Bash(npx agentblog *)
license: MIT
compatibility: A Next.js project with AgentBlog installed. Needs web access to re-verify sources.
metadata:
  package: agentblog
  homepage: https://agentblog.dev
---

# Refresh an existing blog post

Refreshing is not rewriting. The job is to find what has changed in the world since
the post was published and correct the post to match, then leave everything else
alone. A refresh that rewrites prose which was already correct produces a large
diff with no information in it, and makes the real corrections harder to review.

## The rule this skill exists to enforce

**Touch `dateModified` only if the content actually changed.**

Bold, because it is the whole discipline. A `dateModified` that does not correspond
to a real change is a trust-destroying signal, and a refresh tool that bumps the
date unconditionally is a machine for manufacturing exactly that signal at scale.

Freshness is widely reported to correlate with citation. The specific percentages
in circulation do not survive being traced, so do not quote one: the most repeated
of them comes from a study whose publisher's domain no longer resolves. The
qualitative claim is still a reason to genuinely update posts on a schedule. It is
not a reason to restamp them. The engines that reward freshness are the same ones
that can compare the date you claim against the content they already have.

Concretely:

- Fixed a number, replaced a dead citation, added a new section, corrected a claim:
  **update `dateModified`**.
- Fixed a typo, reflowed a paragraph, changed a hyphen, reformatted a table with
  the same contents: **do not update `dateModified`**.
- Verified every fact and found nothing had changed: **do not update
  `dateModified`**, and say so in the report. "Nothing needed changing" is a
  successful outcome of this skill, not a failure.

If you are unsure whether a change counts, ask whether a reader who had already
read the post would want to know about it. If not, the date does not move.

## The other rules, unchanged from writing

These apply to everything you add during a refresh:

- **Never fabricate a statistic, a quotation, or a source.** If a number has moved
  and you cannot verify the new one at a real source you fetched, remove the
  precision and state the claim qualitatively. Do not update a figure to a
  plausible newer figure. That is worse than leaving the old one, because the old
  one at least has a source.
- **Never invent experience.** No anecdote, no first-person story, no opinion the
  sources do not support, added to make a rewritten passage read as human.
- **Never use an em dash.** Not in new prose, not in a corrected sentence, not
  anywhere. Use a comma, a colon, parentheses, or a full stop. Do not use a double
  hyphen as a substitute.
- No keyword stuffing, no padding, and none of "delve", "leverage", "robust",
  "seamless", "landscape", "tapestry". `agentblog audit` fails the post on all of
  them.

## Procedure

### 1. Read the post and its provenance

- Read the post file and its frontmatter.
- Note `datePublished`, `dateModified`, and how long ago that was.
- List every `citations[]` entry, and every number, date, version, price, and
  proper-noun claim in the body. That list is your work queue, and you will report
  against it.
- Check `content/research/` for this post's URL. If `dataforseo-research` has run,
  it may already say what position this post holds, whether it fell without the
  post changing, and which competitor page took the slot. A refresh aimed at a
  measured decline is a different edit from a refresh aimed at stale citations,
  and knowing which one this is before starting saves rewriting the wrong half.

### 2. Re-fetch every cited source

For each entry in `citations[]`, `WebFetch` the URL and check three things:

- **Does it still resolve?** A 404 or a redirect to a homepage means the citation
  is dead. Find the current location of the same source, or replace the source, or
  remove the claim. Do not leave a dead link.
- **Does it still say what the post says it says?** Documentation gets rewritten,
  studies get corrected, vendors quietly restate numbers. This is the check that
  matters most and the one that is easiest to skip. Quote the sentence you read the
  number in.
- **Has it been superseded?** A newer edition, a follow-up study, a newer version
  of the same document.

Record the outcome for each source.

### 3. Re-run the post's searches

`WebSearch` the post's target query and its H2 questions again. You are looking for
facts in the post that are now wrong, sub-questions that did not exist when the
post was written and now do, and better sources for claims the post already makes.

### 4. Apply the smallest correct change

- Update the numbers that moved, with the new source.
- Replace dead or superseded citations, and keep `citations[].kind` accurate to
  what the replacement actually is.
- Add a section only if a genuinely new sub-question needs answering, and write it
  to the same standard as a new post: a 40 to 60 word capsule under the heading,
  150 to 300 words, self-contained, entity names repeated rather than pronouns.
- Leave prose that is still correct exactly as it is.

Do not improve the writing while you are here. If the post needs a rewrite, say so
and stop, rather than smuggling one in under a refresh.

### 5. Decide the date, deliberately

Look at the diff. If it contains only formatting, whitespace, or typo fixes, leave
`dateModified` alone. Otherwise set it to now, in ISO 8601 **with a UTC offset**.

`dateModified` must be greater than or equal to `datePublished`, and it must match
the visible `<time dateTime>` and the JSON-LD exactly. The block renders all three
from frontmatter, so editing frontmatter is the only edit needed.

### 6. Re-run the audit

```bash
npx agentblog audit <slug> --verbose
```

A refresh can break what the original passed: a replaced link can be broken, a
removed paragraph can drop the internal link count below five, a new section can
arrive without a capsule. Report the output. Do not restate its checks by reading.

### 7. Report

State plainly:

- Every source re-checked, with its outcome: unchanged, updated, dead, superseded.
- Every claim that changed, with the old value, the new value, and the source.
- Whether `dateModified` was touched, **and why or why not**.
- Anything you could not verify, and what you did about it.

## When to escalate instead of refreshing

Say so and stop, rather than refreshing, when:

- More than roughly half the post's claims are now wrong. That is a rewrite, and
  `write-blog-post` owns it.
- The post's central premise has been invalidated. Refreshing the numbers around a
  dead premise produces a well-cited wrong article.
- The post targets a query nobody asks any more. Consider a redirect to the post
  that replaced it, rather than an update.
registry/agent/skills/agentblog-audit/SKILL.md
---
name: agentblog-audit
description: >
  Runs the AgentBlog pre-publish gate on a post or a deployed URL. Delegates the
  deterministic checks to the agentblog CLI, then covers the four things no
  script can: what a crawler literally receives, whether the JSON-LD graph is valid
  against the raw HTML, whether every cited source actually says what the post
  claims, and whether the prose reads as written by a person. Reports pass or fail
  per assertion with the observed value. Use before publishing a post, before a
  release, or when asked to audit, gate, or verify a post.
argument-hint: '[post slug or URL]'
disable-model-invocation: true
allowed-tools: Read Glob Grep WebFetch Bash(curl *) Bash(npx agentblog *)
license: MIT
compatibility: A Next.js project with AgentBlog installed. The live sections need a deployed URL.
metadata:
  package: agentblog
  homepage: https://agentblog.dev
---

# Pre-publish audit

Manual invocation only, because an audit that runs opportunistically is an audit
nobody reads. Run it when you mean to gate a publish.

Every assertion below reports **pass or fail with the observed value**. "JSON-LD
validates" is a wish. "`author` is `{ "@id": "https://…#person" }`, pass" is a
check.

**Do not report the post as ready while any assertion fails.**

## 1. Run the deterministic half first

```bash
npx agentblog audit <slug> --verbose
npx agentblog doctor --verbose
```

Between them these cover the answer capsule and its length, links inside capsules,
question-format headings, heading ids, title and description lengths, date offsets
and ordering, evidence presence, internal link counts, broken internal links,
orphan status, hero alt text, copy style, frontmatter validity, and every install
and route check.

Paste the observed output. **Do not re-check any of it by reading.** A second,
softer answer to a question a script already answered is how a failing check gets
reported as a pass, and reading is the softer answer every time.

Both exit non-zero on an error finding and zero on a warning, so read the findings
rather than the exit code. A clean exit with three warnings is not a pass.

Note what `audit` has no flags for. Every check runs on every post, on purpose:
the checks a writer would switch off are the ones that catch the expensive
mistakes. Narrow the input with a slug or `--dir`, never the checks.

Sections 2 through 6 are what those commands cannot reach.

## 2. The raw crawler fetch, which nothing replaces

```bash
curl -s -A "GPTBot" "$URL" | grep -q "$DISTINCTIVE_SENTENCE"
curl -s -A "GPTBot" "$URL" | head -c 4000 | grep -q "<title>"
```

Keep this check exactly as it is, with no tooling around it. Its entire value is
that it uses none: it tests what a non-JavaScript crawler literally receives, with
no bundler, no browser, and no framework in the path. Every other instrument in
this file tells you what the system believes. This one tells you what went over the
wire.

Pick the distinctive sentence from the middle of the article body, not from the
capsule or the title, so a page that renders only its shell fails.

The second assertion, `<title>` inside the first 4000 bytes, is checking placement
rather than existence. Next.js appends deferred metadata into `<body>` for user
agents it does not classify as HTML-limited, so a `<title>` that appears late in
the document is a `<title>` the crawler read as body text.

For the multi-bot sweep and CDN detection, `npx agentblog doctor --url $URL` already
probes GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, and Googlebot and names the
CDN. Run it from outside the deployment's own network: a request originating inside
can bypass the CDN rule the check exists to find. A 403, a 429, or a challenge page
is a **blocking** failure, not a warning.

## 3. Better instruments than a full build

Do not shell out to `next build` to answer "does this compile" or "is the article in
the first response byte". Two faster instruments exist:

- **The Next.js MCP server at `/_next/mcp`** exposes the running dev server's
  routes, server logs, and compilation issues. Its `get_compilation_issues` and
  `compile_route` tools answer the compilation question in the time a build spends
  starting up.
- **`agent-browser`** (https://github.com/vercel-labs/agent-browser) exposes DOM,
  console, network, and Web Vitals as structured text, including which Suspense
  boundaries are still pending. That is a better instrument for the Core Web Vitals
  thresholds and for "what is actually in the first chunk" than anything we would
  write.

Use them. Keep the curl.

## 4. Schema assertions

Run these against the **raw HTML** from the curl above, never against the rendered
DOM. A DOM-based validator passes markup a crawler never receives.

| Assertion                                                                                     | Why it is here                                                                                                                                                                                       |
| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Every marked-up fact is visible on the page                                                   | "Don't mark up content that is not visible to readers of the page" is the single most enforced structured data policy, and a violation can earn a manual action that removes rich-result eligibility |
| If `FAQPage` is present, the FAQs render in the HTML                                          | Same policy, and the most common way blogs trip it                                                                                                                                                   |
| `author` is a linked `Person` node with `@id` and `url`, never a bare string                  | A bare string loses the entity link that E-E-A-T depends on                                                                                                                                          |
| `author.name` contains no job title, honorific, or publisher name                             | Explicit in Google's Article guidance. `Author.jobTitle` exists for exactly this                                                                                                                     |
| Every `sameAs` entry is an absolute URL, not a handle                                         | A handle disambiguates nothing                                                                                                                                                                       |
| `Organization.logo` has `width` and `height`                                                  | Required shape, and the config carries both                                                                                                                                                          |
| `datePublished` and `dateModified` carry a UTC offset and match the visible `<time dateTime>` | Three sources of the same date is three chances to disagree. Report the three observed values side by side, not a boolean                                                                            |
| `image` is at least 50,000 pixels and supplied at 16:9, 4:3, and 1:1                          | Google's stated recommendation for Article images                                                                                                                                                    |
| Exactly one `<script type="application/ld+json">` on the page, containing one `@graph`        | Two serialization points is how a graph starts contradicting itself                                                                                                                                  |
| `BlogPosting`, `WebPage`, `Person`, and `BreadcrumbList` are all present and linked by `@id`  | A disconnected node describes an unrelated thing rather than this page                                                                                                                               |
| The final `BreadcrumbList` item has no `item` property                                        | Correct for the current page, and flagged as an error by validators when present                                                                                                                     |

Then run both external validators and report both, because they answer different
questions:

- Rich Results Test (https://search.google.com/test/rich-results): rich-result
  eligibility.
- Schema Markup Validator (https://validator.schema.org/): full vocabulary validity.

## 5. Evidence, which is the assertion with no instrument

`agentblog audit` can tell you a number has no citation. It cannot tell you a
citation is wrong, and a confidently wrong citation is worse than a missing one.

For each entry in `citations[]`:

- **Fetch the URL.** Report whether it resolves, and whether it redirects.
- **Find the claim in it.** Quote the sentence the post's number came from. If you
  cannot find it, the assertion fails, regardless of how plausible the number is.
- **Check the grade.** `kind` should match what the source actually is:
  `peer-reviewed`, `official-docs`, `industry`, `news`, `other`. An industry blog
  filed as `peer-reviewed` is a misrepresentation the reader cannot see.

Report every source with its outcome. This section is slow and it is the reason the
audit exists.

## 6. Copy style and voice

`agentblog audit` covers the mechanical bans: the em dash, the double hyphen used
as a dash, "delve", "leverage", "robust", "seamless", "landscape", "tapestry", the
stock opener, and the "it is not just X, it is Y" construction. Report its result
and move on.

What is left needs reading:

- No claim phrased as evidence without a source. "Studies show", "experts agree",
  "industry reports suggest".
- No invented experience: no anecdote, no first-person story, no opinion the
  sources do not support.
- No closing flourish. No summary recap, no final metaphor inflating the point.
- No run of three sentences sharing a shape.

The full catalogue is in the `write-blog-post` skill's `references/voice.md`.

## 7. Publish-path assertions

- The publish webhook revalidates `/sitemap.xml` and `/feed.xml` as well as the
  post and index paths. Both are cached route handlers, so a publish that skips
  them pings IndexNow for a URL the site's own sitemap does not list.
- `revalidateTag` on the publish path uses `{ expire: 0 }`, not `'max'`. `'max'` is
  stale-while-revalidate, which invites the crawler you just summoned to be the
  visit that receives the stale body.
- No internal link resolves through a redirect. AI crawlers handle redirect chains
  poorly: Vercel and MERJ measured ChatGPT spending 14.36% of its fetches following
  redirects and 34.82% of them on 404 pages
  (https://vercel.com/blog/the-rise-of-the-ai-crawler, 17 December 2024).

`publish-blog-post` owns the rest of the publish sequence, including the IndexNow
response code. Hand off rather than duplicating it.

## 8. Fleet-level checks

```bash
npx agentblog audit --stale --days 90
npx agentblog audit --crawlers ./access.log
```

`--stale` ranks posts by `dateModified` age weighted by inbound internal links, so
the highest-authority stale posts surface first. Hand those to `refresh-blog-post`,
and note that skill's rule: a refresh updates `dateModified` only when the content
actually changed.

`--crawlers` parses a server or CDN access log and reports hits per bot per week.
A post nothing fetched is not a content problem.

## Reporting

Group findings by severity. A finding that is silently wrong outranks a finding
that is loudly wrong, because on this product every failure worth catching is
silent.

1. **Blocking**: a crawler receives no body text, a CDN returns 403, the article
   body is client-rendered, marked-up content is not visible on the page, a cited
   source does not contain the claim.
2. **Error**: schema assertions, date integrity, orphan post, missing capsule,
   anything `agentblog audit` reports as an error.
3. **Warning**: capsule length off by a few words, link count outside the range,
   copy style, voice.

Report every assertion, including the ones that passed. An audit that lists only
failures gives no evidence it ran the rest.
registry/agent/skills/publish-blog-post/SKILL.md
---
name: publish-blog-post
description: >
  Publishes a finished AgentBlog post and proves it landed: gates on agentblog
  audit, ships the change, revalidates the post, index, sitemap, and feed, submits
  to IndexNow, reports the real response code, then verifies as a crawler that the
  live URL serves the article body. Use before publishing, shipping, or going live
  with a post, when asked to submit a post to IndexNow, or when a published post is
  missing from the sitemap or serving stale content.
argument-hint: '[post slug]'
disable-model-invocation: true
allowed-tools: Read Glob Grep Bash(curl *) Bash(npx agentblog *) Bash(git status *) Bash(git diff *)
license: MIT
compatibility: A deployed AgentBlog site. Needs INDEXNOW_KEY and AGENTBLOG_REVALIDATE_SECRET.
metadata:
  package: agentblog
  homepage: https://agentblog.dev
---

# Publish a post, and prove it published

Manual invocation only. Publishing pings an external service and puts a URL in
front of crawlers, and an agent should not decide on its own that a draft looks
ready.

Everything in this sequence has a silent failure mode. IndexNow answers `403` for
an invalid key and `422` for a host mismatch, and from the caller's side both look
exactly like success. A sitemap that was not revalidated still returns `200`, just
without the new post in it. The whole skill is the habit of reading the code
instead of the absence of an error.

```
- [ ] 1. Gate
- [ ] 2. Confirm the post is actually publishable
- [ ] 3. Ship it
- [ ] 4. Revalidate and submit
- [ ] 5. Read the IndexNow code
- [ ] 6. Verify as a crawler
- [ ] 7. Report
```

## 1. Gate

```bash
npx agentblog audit <slug> --verbose
npx agentblog doctor --verbose
```

**Both must be clean, warnings included.** They exit non-zero on an error finding
and zero on a warning, so a successful exit code with three warnings under it is
not a pass. Read the findings.

Do not publish past a finding by deciding it is minor. Publishing is the step that
makes a mistake expensive: once a URL is submitted, a correction needs a second
submission and the first version may already have been read.

If the post has not been through `agentblog-audit`, run that first. It covers the
assertions the CLI cannot make, including whether the cited sources actually say
what the post claims.

## 2. Confirm the post is actually publishable

- `draft` is `false` or absent. A `draft: true` post is excluded from every query,
  the sitemap, and the feed, so publishing it does nothing and reports success.
- `datePublished` is not in the future.
- `dateModified` is greater than or equal to `datePublished`, and both carry a UTC
  offset.
- The slug matches the file name. Report both, because a `slug` in frontmatter
  silently overrides the file name, and you are about to submit a URL built from
  the frontmatter value.
- `git status` is clean apart from this post and the file you edited to add an
  inbound link. Publishing sweeps up whatever else is staged.

## 3. Ship it

Merge or deploy by whatever route the project uses. Wait for the deployment to
finish before step 4. Revalidating a route the new build has not replaced yet
caches the old body, and the next crawler visit is the one that reads it.

If `agentblog.config.ts` sets a `deployHook`, the adapter's `prerenderStrategy` is
`'deploy-hook'` and the deploy is what regenerates the static pages. Without the
hook configured, publishing without a rebuild serves stale content and reports
nothing.

## 4. Revalidate and submit

```bash
npx agentblog ping <slug>
```

One command does the whole publish path. It revalidates `/blog/<slug>`, `/blog`,
`/sitemap.xml`, and `/feed.xml`, then submits the post URL to IndexNow and prints
the real response code with its meaning and a remedy.

The two metadata routes are in that list because they are the ones that get
forgotten. `sitemap.xml` and `feed.xml` are cached route handlers, and
`generateStaticParams` does not re-run during ISR, so a publish path that skips
them pings IndexNow for a URL the site's own sitemap does not list. That is the
worst possible combination: you have invited a crawler to a page your own site
does not claim to have.

It exits non-zero when any step you asked for did not happen, including a missing
credential. A missing `AGENTBLOG_REVALIDATE_SECRET` or `INDEXNOW_KEY` is an error
rather than a warning, because a publish step that silently submits nothing and
reports success is the failure this command exists to make loud. Use
`--skip-revalidate` or `--skip-indexnow` only when you actually mean to skip one.

## 5. Read the IndexNow code

`ping` prints it. Report it verbatim rather than reporting "submitted".

| Code  | Meaning                            | What to do                                                                    |
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- |
| `200` | Submitted                          | Nothing.                                                                      |
| `202` | Accepted, key validation pending   | Confirm the key file is actually deployed at the domain root. Re-check later. |
| `400` | Bad payload format                 | A bug in the tooling. Report it.                                              |
| `403` | Key invalid or missing at the root | The key file is not reachable at `https://<host>/<key>.txt`. Deploy it.       |
| `422` | URL and host do not match          | The submitted URL is not on the host that owns the key. Check `siteUrl`.      |
| `429` | Rate limited                       | Wait, then submit fewer URLs.                                                 |

`403` and `422` are the two that matter, because they are indistinguishable from
success unless somebody reads the number. The IndexNow limit is 10,000 URLs **per
request**, not per domain per day, so batching is rarely the problem.

## 6. Verify as a crawler

The publish is not finished until a crawler can read the post.

```bash
curl -s -A "GPTBot" "https://<host>/blog/<slug>" | grep -q "$DISTINCTIVE_SENTENCE"
curl -s "https://<host>/sitemap.xml" | grep -q "/blog/<slug>"
curl -s "https://<host>/feed.xml" | grep -q "/blog/<slug>"
```

Pick the distinctive sentence from the middle of the article body, not from the
capsule or the title, so a page that serves only its shell fails.

Run these **from outside the deployment's own network**. A request originating
inside can bypass the CDN rule that would block a real crawler.

Then:

```bash
npx agentblog doctor --url https://<host>/blog/<slug>
```

That probes the URL as GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, and
Googlebot and names any CDN in front of it. A 403 or a challenge page for any of
them is a blocking failure, and it is the single most common reason a correctly
built blog gets no citations.

If the sitemap does not contain the slug, the revalidation did not take. Re-run
step 4 rather than assuming propagation.

## 7. Report

- The audit and doctor results, with observed values.
- The IndexNow response code and its meaning.
- Whether `/sitemap.xml` and `/feed.xml` now contain the URL, quoted.
- The crawler fetch result per user agent, with the sentence you tested with.
- Anything skipped, and why.

**Do not report the post as published while any of those is unverified.** "Deployed"
and "published" are different claims, and only one of them has been tested here.
registry/agent/AGENTS.agentblog.md
<!-- agentblog:start -->

## Blog (AgentBlog)

Posts live in `content/blog/*.mdx`. Config: `agentblog.config.ts`. Authors:
`content/authors.json`. Categories: `content/categories.json`.

Seven AgentBlog skills cover the lifecycle. Use them by name rather than working
from memory: they carry the schema contract, the citation rules, and the checks,
and hand-doing any of these steps silently skips all three.

| Task                                     | Skill                 |
| ---------------------------------------- | --------------------- |
| Finish the install, make the blog ours   | `agentblog-setup`     |
| Find out what to write, with real data   | `dataforseo-research` |
| Decide what to write and how it links up | `plan-blog-content`   |
| Write a post                             | `write-blog-post`     |
| Update an existing post                  | `refresh-blog-post`   |
| Gate a post before it ships              | `agentblog-audit`     |
| Ship it and confirm it landed            | `publish-blog-post`   |

- Never fabricate a statistic, a quotation, or a source. If a number cannot be
  verified at a real source that was actually fetched, state the claim
  qualitatively instead. Never invent an anecdote, a first-person story, or an
  opinion to make prose read as human.
- Never use an em dash, in a post or anywhere else in this repository. Use a comma,
  a colon, parentheses, or a full stop. `agentblog audit` fails on the em dash
  character `U+2014` and on a double hyphen used as a dash.
- Never remove or narrow `htmlLimitedBots` in `next.config.ts`. The value replaces
  Next.js's default bot list rather than extending it, so it must stay a superset of
  that list plus the AI crawlers. Narrowing it breaks Google, Bing, and social
  previews as well as AI citation.
- Never add `'use client'` to anything in the article render path. Only
  `TableOfContents` and `ShareButtons` may be client components, and both render
  meaningful content server-side first.
- `generateStaticParams` in `app/blog/[slug]/page.tsx` must return every slug, never
  a slice and never a page.
- `dateModified` changes only when the content actually changed. An unearned date
  bump is a trust-destroying signal, not a freshness signal.
- Every route that sets `openGraph` or `robots` must spread the shared defaults from
  `lib/metadata.ts`. Metadata merge is shallow, so defining the object at all
  replaces the parent's entire object.
- JSON-LD is built only in `lib/schema.ts` and serialized only by `renderJsonLd`.
  Do not add a second `<script type="application/ld+json">`.
- Run `npx agentblog audit` before committing a post, and `npx agentblog doctor`
  after changing config. Both exit non-zero on an error finding, so read the exit
  code rather than the absence of a stack trace.

<!-- agentblog:end -->

More from agentblog

Browse all components in this library →