# MCP tools

> Every tool the DispatchSEO MCP server exposes - what it does, its parameters, and what it returns.

Source: https://pulseseoapp.com/docs/mcp-tools
All docs in one file: https://pulseseoapp.com/llms-full.txt

Everything below is the same server your agent already talks to. This
page exists so you can look up one tool's exact contract without asking
your agent to read the source for you.

## Overview

The server lives at **`/api/mcp`** over streamable HTTP - no SSE, no
Redis, one endpoint for every connected site. What tells your project's
data from anyone else's is the bearer token you connect with: the token
**is** the tenant. There's no separate "pick a project" step and no way
for one token to see another project's rows.

Every tool returns pretty-printed JSON as its response text, so your
agent gets structured data back, not prose to parse. A call that fails
comes back as an error with a plain-English message - usually naming the
actual fix (a missing credential, a cooldown, a bad id) rather than a
raw database error.

The MCP is a door to **state**, not a research or writing tool. It reads
and writes the suggestions queue, tracked keywords, published pages, GSC
stats, and backlink prospects - the same tables the dashboard reads and
writes. It does not call DataForSEO on your behalf and it does not
generate content: your agent does the thinking, using its own research
tools (including DataForSEO's own MCP server, if you run one) plus the
two thin research primitives below - `check_serp` and `suggest_keywords`
- that route through whatever keyword source your project is set up
with.

Parity is the rule that keeps the two faces of the product honest:
anything the dashboard can do, your agent can do here, and vice versa.
If a dashboard screen doesn't have a matching tool on this page, that's
a gap worth reporting, not a feature you should expect to live without.

### Connecting

Your dashboard's Settings screen shows a ready-to-paste connect command
per agent, with your project's slug, domain, and key already filled in -
copy that one rather than typing any of these by hand.

<AgentTabs>
<AgentTab id="claude">

```bash
claude mcp add dispatchseo-<slug> <your-domain>/api/mcp \
  --transport http --scope local \
  --header "Authorization: Bearer <token>"
```

`--scope local` pins the connection to the repo folder you run it in, so
a second connected project never leaks into an unrelated repo and starts
answering for the wrong site.

</AgentTab>
<AgentTab id="codex">

```bash
codex mcp add dispatchseo-<slug> --url "<your-domain>/api/mcp?key=<token>"
```

Identical in PowerShell - there's no header to quote. Codex has no
`--scope local`; the per-project server name is what keeps two connected
sites apart. [The Codex guide](/docs/install-codex) covers the rest.

</AgentTab>
<AgentTab id="cursor">

Cursor has no `mcp add` command - you write the config file, then approve it:

```bash
mkdir -p .cursor    # then add this server to .cursor/mcp.json:
```

```json
{
  "mcpServers": {
    "dispatchseo-<slug>": {
      "url": "<your-domain>/api/mcp",
      "headers": { "Authorization": "Bearer <token>" }
    }
  }
}
```

```bash
cursor-agent mcp enable dispatchseo-<slug>
```

The `enable` step is not optional: an unapproved server loads no tools at
all. Your dashboard has a one-paste version that merges into an existing
config for you. [The Cursor guide](/docs/install-cursor) covers the rest.

</AgentTab>
</AgentTabs>

**Anything else** that speaks streamable-HTTP MCP: point it at
`<your-domain>/api/mcp` with an `Authorization: Bearer <token>` header,
or `?key=<token>` on the URL if it can't set headers. Same gate, same
tools. See [docs/AGENTS.md](https://github.com/NeoZi12/dispatchseo/blob/main/docs/AGENTS.md)
for where each agent stands.

<Callout type="note" title="Windows: use the URL variant">
Claude Code on Windows has a long-lived bug where a configured
`--header` is stored and `claude mcp list` reports Connected, but real
tool calls go out with no Authorization header at all. The key can ride
in the URL instead - `<your-domain>/api/mcp?key=<token>` - which
survives that bug. The dashboard hands you this exact form on Windows,
no `--header` involved.
</Callout>

## Suggestions queue

The queue is the heart of the product: every idea - guide, tool,
backlink, or update - starts here as `pending`, and a build workflow
only ever touches what the owner (or auto-approve) moved to `approved`.

### `get_suggestions`

Lists items in the queue. Defaults to `approved` - which is what a build
workflow asks for when it wants to know what to work on next. Items come
back in build order (front-placed ideas first, then oldest first), so a
workflow can simply take the first one.

<Fields>
  <Field name="status" type="string">
    One of `pending`, `approved`, `rejected`, `in_progress`, `done`.
    Defaults to `approved`.
  </Field>
  <Field name="type" type="string">
    Filter to `guide`, `tool`, `backlink`, or `update`.
  </Field>
</Fields>

Returns the matching rows, sorted in build order.

### `propose_suggestion`

Adds one new idea to the queue, normally landing as `pending` for the
owner to decide on the dashboard. Use it after research - one call per
idea.

<Fields>
  <Field name="type" type="string" required="true">
    `guide`, `tool`, `backlink`, or `update`.
  </Field>
  <Field name="title" type="string" required="true">
    The idea's title.
  </Field>
  <Field name="primary_keyword" type="string">
    The keyword this idea targets.
  </Field>
  <Field name="volume" type="number">
    Monthly search volume, if known.
  </Field>
  <Field name="kd" type="number">
    Keyword difficulty, if known.
  </Field>
  <Field name="rationale" type="string">
    Why it's worth doing - volume, KD, intent, the gap it fills.
  </Field>
  <Field name="spec" type="object">
    A free-form brief: an outline/angle/SERP notes for a guide, the
    functionality for a tool, the target url for a backlink. A take
    grown from one specific viral post can add `spec.seed_url` (+
    `spec.seed_stats`) - the guide builder writes from that source
    directly: credits it, pulls real quotes, embeds it, then covers
    what the original missed.
  </Field>
  <Field name="source" type="string" tag="Honor system">
    `research` (the default), `trend-scan`, or `manual`. Trend
    workflows must pass `trend-scan` - it's what puts the idea on the
    Trend radar with the owner-only approval gate. `manual` is only for
    ideas the site owner dictated in the current conversation
    ("add a guide about X"), never for an autonomous run.
  </Field>
  <Field name="trend_topic_id" type="uuid">
    Only from the trend-expand workflow - groups this take under its
    radar subject.
  </Field>
  <Field name="approved" type="boolean" tag="Manual only">
    Skips the pending gate and lands the idea straight in the build
    queue. Only takes effect when `source` is `manual` - an autonomous
    run passing `approved:true` without `source:"manual"` still lands
    `pending`.
  </Field>
  <Field name="position" type="string">
    `front` or `back`. `front` means "do this one next" - since guides
    ship one per morning, front means tomorrow.
  </Field>
  <Field name="build" type="string">
    `now` fires the tool builder immediately instead of queueing. Only
    has an effect for `type:"tool"` combined with a manual approval.
  </Field>
</Fields>

Returns the created row, or `{ note, suggestion }` when front-placement
or an instant build changed what happened.

### `update_suggestion`

Updates a suggestion's status and/or attaches a PR url. A build workflow
marks an item `in_progress` when it starts and `done` with
`result_pr_url` when the PR opens.

<Callout type="note" title="Approval coercion">
An agent asking for `status:"approved"` normally lands as `pending`
instead, so the owner still decides on the dashboard - the response
says so, and that's success, not a failure to retry. Passing
`decided_by:"owner"` is the one thing that unlocks a real approval: it
means the site owner just made the call in this conversation ("approve
that one", "reject it", "restore the X idea"). Never set it from an
autonomous workflow run. Owner-approving a trend-scan idea also puts it
at the front of its queue, and owner-approving a rejected item restores
it from History.
</Callout>

<Fields>
  <Field name="id" type="uuid" required="true">
    The suggestion to update.
  </Field>
  <Field name="status" type="string">
    `pending`, `approved`, `rejected`, `in_progress`, or `done`.
    Approving/rejecting stamps `decided_at`; `done` stamps
    `completed_at`.
  </Field>
  <Field name="result_pr_url" type="string">
    The opened PR's url, typically paired with `status:"done"`.
  </Field>
  <Field name="decided_by" type="string" tag="Owner only">
    `owner` or `agent`. Only `owner` bypasses the approval coercion
    above.
  </Field>
</Fields>

Returns the updated row, with a `note` when the approval was coerced to
pending or when a tool build was (or wasn't) dispatched.

### `reorder_queue`

<Pill tone="amber">Owner only</Pill>

Rewrites the build order of one queue - the MCP side of the dashboard's
drag-to-reorder. Guides and tools are separate queues. Only use this
when the site owner asked to re-prioritize in the current conversation,
never from an autonomous run.

<Fields>
  <Field name="group" type="string" required="true">
    `guide` or `tool`.
  </Field>
  <Field name="ordered_ids" type="array" required="true">
    The queue's approved suggestion ids, in the exact order they should
    build (first id builds next). Read the current order with
    `get_suggestions` first. Ids you omit keep their place after the
    ordered set.
  </Field>
</Fields>

Returns `{ reordered: true, group, order }`.

### `build_suggestion_now`

<Pill tone="amber">Owner only</Pill> <Pill tone="neutral">Tools only</Pill>

The dashboard's "Build now" button. Approves a **tool** suggestion (if
it isn't already) and wakes the tool builder immediately. Guides have no
instant build, by design: at most one guide ships per day so the
publishing pace stays steady instead of bursty, so a guide id is instead
approved and placed at the front of its queue - the next daily build
picks it up, tomorrow at the latest. Only use this when the site owner
explicitly asked for it in the current conversation; autonomous runs
queue via `propose_suggestion` and wait.

<Fields>
  <Field name="id" type="uuid" required="true">
    The suggestion to build now.
  </Field>
</Fields>

Returns `{ ok: true, message }` for a tool, or `{ ok: true, note }` for a
guide (explaining the front-of-queue placement instead).

### `get_build_brief`

The guide build's opening call - the build-guide playbook says to make it
**first**, before anything else. One response carries what a run used to
collect over a dozen serial calls: the queue head (the approved guide - or
refresh-detector `update` - to
build, in the owner's exact build order, sorted by the same helper
`get_suggestions` uses so the two can't name different items), how deep that
queue is, the live pacing verdict, structural fingerprints of the last few
published guides, ranked internal-link candidates when the project has
back-linking on, and the site's own GSC numbers for the information-gain
step. The fingerprints are the real saving: each one is a published guide's
opening word-run and its heading skeleton with the topic stripped, computed
by the same extractor `check_sameness` judges the draft with, so the run
never has to read two or three published posts in full just to learn their
shape.

Every section fails open on its own. Anything unavailable is named in
`degraded` along with the fallback to use for that one section - a degraded
brief is never a reason to stop a build.

<Fields>
  <Field name="keyword" type="string">
    The new guide's primary keyword, used to rank internal-link candidates by
    topical closeness. Omit it on a first call when the queue head is what
    tells you the keyword.
  </Field>
</Fields>

Returns `{ queue_head, queue_depth, pacing, recent_guides, link_candidates,
site_stats, internal_linking, degraded }`. `link_candidates` is only
populated when `set_internal_linking` is on. An empty approved queue arrives
as a `degraded` line too - it's a real state, not a lookup failure, and the
playbook has a backstop for it.

### `check_sameness`

The pre-publish sameness gate: does this draft read like the guides this
site already published? Pass the full guide markdown (frontmatter and
code fences are ignored) plus its primary keyword, and the backend
compares it against this project's most recently published guides for an
identical opening, a heading skeleton that's the same once the topic is
removed, and stock phrases repeated across the catalogue. A failure
means rewrite the flagged elements and call again - never loosen the
check. It fails open (passes, with a note) if the published corpus can't
be read, so a network or database hiccup can never block a build.

<Fields>
  <Field name="markdown" type="string" required="true">
    The full draft guide.
  </Field>
  <Field name="primary_keyword" type="string">
    Stripped out of both the draft and the published corpus before
    comparing, so two guides about different topics with the same
    shape still get caught.
  </Field>
</Fields>

Returns `pass` (boolean), `compared_against` (how many published guides
it checked against), and `flags` - the exact offending strings when it
fails.

## Articles and publishing

The other way an article reaches a site. The original route has a coding
agent in your own CI write the article and open the pull request; this
one takes a finished article straight from your AI and publishes it for
you - to WordPress, or as a pull request our server opens in your repo.
Either way your AI hands the article over and stops there.

### `submit_article`

Hand in a finished article as plain markdown. The server runs it through
the publishing gate, stores it as a draft, then formats it, inserts
internal links into your existing pages, builds the meta description,
FAQ schema and cover image, and publishes it on your schedule. Your
agent does not publish anything itself and does not need to know how
your site works.

The gate is the point. It runs here rather than inside the writing
agent, because a model checking its own draft passes it - so the same
24 checks apply whether the article came from Claude Code, Codex, the
Claude app, or ChatGPT. A failed submission comes back with the exact
checks that failed and a `fix` line for each one, written to be acted on
without a human: apply them and call `submit_article` again with the
same `suggestion_id` and it updates the same draft. Three rejected
attempts is the point to stop and tell the owner what's blocking it.

Where it ends up is the site's own setting, not something to pass here.
On the WordPress route the article is posted for you; on the GitHub
route our server commits it into the folder your site already keeps its
articles in and opens a pull request (merged automatically if the site
is on automatic mode). `set_publish_target` changes the door, and
`get_drafts` says where each article got to.

<Fields>
  <Field name="suggestion_id" type="string">
    The queued idea this article fulfils. Passing it marks the idea
    `in_progress` and keeps one live article per idea - a resubmission
    updates that draft instead of adding another. Omit it only for an
    article the owner asked for that was never in the queue.
  </Field>
  <Field name="title" type="string" required="true">
    The title as it should appear on the page.
  </Field>
  <Field name="slug" type="string" required="true">
    Lowercase and hyphenated, no leading or trailing slash.
  </Field>
  <Field name="meta_description" type="string">
    Roughly 120-160 characters. Omitted means one is built from the
    opening.
  </Field>
  <Field name="primary_keyword" type="string" required="true">
    The single search term the article targets, exactly as someone would
    type it.
  </Field>
  <Field name="markdown" type="string" required="true">
    The full article. No frontmatter, no raw HTML, and not wrapped in a
    code fence.
  </Field>
  <Field name="faq" type="array">
    `{ question, answer }` pairs for the FAQ block and its schema. Each
    question ends in "?" and each answer is a couple of real sentences.
  </Field>
  <Field name="sources" type="array">
    `{ url, title? }` for sources actually used. Placeholder and
    example.com URLs fail the gate.
  </Field>
  <Field name="cover_brief" type="string">
    One line describing the cover image's subject. Never published as
    text.
  </Field>
</Fields>

A site can hand in **5 articles a day** and have **3 waiting to publish**
at once. Neither number is about our costs - the finisher calls no model,
so an article costs us a fraction of a cent. They're about yours:
publishing a week of content in an afternoon is the pattern search
engines read as a bulk-generated site, which costs you more than the
extra articles are worth. Both caps refuse by name and store nothing, a
rejected article never counts against either, and a resubmission of
something already in flight always goes through.

Accepted returns `{ draft_id, status: "submitted", score, advisory,
note }` - `score` is 0-100 across every check, and `advisory` lists the
non-blocking ones that failed, worth fixing next time but not worth a
round trip now. Rejected comes back as an error carrying `draft_id`,
`score`, and `blocking` - one `{ id, title, fix }` per check that must
pass. An article for an idea we've already accepted is refused by name,
not by a database error.

### `get_drafts`

Every article submitted through `submit_article` and what happened to it -
the same list the owner's Drafts screen shows. Call it after a rejection:
the blocking checks and their `fix` lines come back here too, so a new
chat can pick up where the last one left off without resubmitting blind.

The statuses, in the order an article moves through them:

| Status | What it means |
|---|---|
| `submitted` | Passed the gate, queued for formatting |
| `rejected` | Sent back - `problems` says what to fix |
| `accepted` | Formatted and queued to publish |
| `blocked_setup` | Nowhere to publish yet; `blocked_reason` says what's missing |
| `finished` | Posted to WordPress, or a pull request opened in the repo - not yet confirmed live |
| `published` | Confirmed live |
| `discarded` | The owner threw it away |

<Fields>
  <Field name="status" type="string">
    Return only this status. Omit for everything.
  </Field>
  <Field name="limit" type="number">
    How many, newest first. Defaults to 20, max 50.
  </Field>
</Fields>

Returns one compact object per draft: `{ id, suggestion_id, title, slug,
status, score, problems, published_url, pr_url, created_at, updated_at }`.
Only the checks that FAILED come back in `problems` - the full report is
stored, but twenty passing checks are not worth your context. `pr_url` is
set only on the GitHub route, and is where the owner reviews the article
until the page is confirmed live.

### `approve_draft`

Publish a finished article right now instead of at the site's usual
publishing hour. Owner-gated: an accepted article already publishes on
its own, so an autonomous run has no reason to call this. It's also how
you release an article that parked as `blocked_setup`, once the owner has
connected their site.

<Fields>
  <Field name="id" type="string" required="true">
    The draft id from `get_drafts` or `submit_article`.
  </Field>
</Fields>

Refuses by name rather than queueing work that can't run: an article
that isn't prepared yet, one that's already live, a discarded one, or a
site with nowhere to publish all come back as an error saying which.
When the article already has a publish queued for the site's usual hour,
that job is pulled forward to now rather than left waiting - "now" means
now.

### `discard_draft`

Throw an article away so it is never published. Owner-gated. The row is
kept as history rather than deleted, so "why did that article never
appear" stays answerable. A rejected draft doesn't need discarding - fix
it and resubmit instead.

<Fields>
  <Field name="id" type="string" required="true">
    The draft id from `get_drafts` or `submit_article`.
  </Field>
</Fields>

### `get_site_digest`

What your site already covers, read for you. Our server crawls the site
when you connect it and again every week, and keeps a compact summary:
the sections it has, how many pages, the newest and oldest, sample
titles, the most-linked pages, typical article length.

Call it before researching or writing. It's how your AI avoids
proposing an article you already have, and how it matches your site's
voice and depth - and reading 150 pages instead would cost more context
than the article is worth, which is the whole reason this exists.

Returns `{ crawled, digest, notes, page_count, source, crawled_at }`.
`notes` names anything the crawl couldn't see (a page cap it hit, pages
that wouldn't load), so a partial picture says so rather than passing
itself off as the whole site. A site we haven't read yet comes back as
`{ crawled: false, note }` - a real state, not an error.

The full page inventory the internal linker uses is deliberately not in
here. It's an order of magnitude larger, our finisher is the only thing
that reads it, and folding it in would break the context budget this
summary exists to respect.

### `rescan_site`

Queue a fresh read of the site. It runs in the background; call
`get_site_digest` a few minutes later for the result. The site is
re-read weekly anyway, so this is for when something changed that
matters now - a restructure, a new section, pages published elsewhere.
Calling it twice in a day queues one read and tells you so
(`already_queued: true`) rather than pretending to queue a second.

### `set_publish_target`

Choose where finished articles go: `wordpress` posts them straight to
your site, `github` commits the article to the connected repo and opens
a pull request (merged automatically in automatic mode), `api` publishes
into the project's own [Content API](/docs/content-api) for the site to
pull with a delivery key, `manual` finishes the article and leaves it
for you to place. Owner-gated.

<Fields>
  <Field name="target" type="string" required="true">
    `github`, `wordpress`, `api`, or `manual`.
  </Field>
</Fields>

It does **not** connect anything. The WordPress username and application
password are entered on the Settings screen and never travel through the
MCP - same rule as every other credential. Setting `wordpress` before
that connection exists is allowed, and the response says plainly that
articles will wait until it's made. Choosing `api` mints the delivery
key if it doesn't exist yet; the key is shown only under Settings -
Content API, never here.

### `set_content_api`

Configure the Content API route (switch onto it with
`set_publish_target` `api`): where the site serves the articles, and the
optional deploy hook we ping after each publish. Owner-gated. The same
writes as Settings - Content API; the delivery key itself is
dashboard-only and is never returned by any tool.

<Fields>
  <Field name="blog_base_path" type="string">
    Path the site serves articles under, e.g. `/blog` (the default).
    Canonical URLs and the post-publish live check both use it. Empty
    string resets to the default.
  </Field>
  <Field name="rebuild_webhook_url" type="string">
    Public `https://` deploy hook (Vercel/Netlify) POSTed after each
    publish so a static site rebuilds. Empty string clears it.
  </Field>
</Fields>

### `set_content_path_hint`

Repo route only. The folder in the connected GitHub repo that holds the
site's articles (for example `content/blog`). Leave it unset and the
publisher works it out from the repo's layout; set it when `get_drafts`
reports a draft parked on "where your articles live", or when the owner
tells you the folder. An empty string clears it. Owner-gated. The same
field as Settings - Where your articles live.

<Fields>
  <Field name="path" type="string" required="true">
    Repo-relative folder, plain characters only, no `..`. Empty string
    clears the hint.
  </Field>
</Fields>

### `save_research_notes`

Store what you learned researching an idea, so a **later** conversation
can write the article without paying to redo the research.

This is what makes the split work on a chat plan. A coding agent
researches and writes in one long run and keeps the findings in its
context; Claude.ai or ChatGPT has neither the window nor the continuity
for that, so one chat researches, another writes, and this is the
handover between them.

<Fields>
  <Field name="suggestion_id" type="string">
    The queued idea this research is for. Saving again for the same id
    replaces the previous notes, so the writing session never has to
    guess which of two versions is current. Omit for research that isn't
    tied to a queued idea.
  </Field>
  <Field name="notes" type="object" required="true">
    Your findings as a JSON object - SERP picture, competitor gaps, the
    angle, an outline, numbers with their sources. Shape it however suits
    the work; nothing on our side parses it. Capped at 64KB, which is
    room for conclusions and not room for three competitor articles
    pasted in whole. Over that it's refused by name, and whatever was
    saved before stays intact.
  </Field>
</Fields>

### `get_research_notes`

Read back research an earlier conversation saved. Call it first in a
writing session: if notes exist for the idea you're about to write, the
research is done. Pass `suggestion_id` for one idea's notes, or omit it
to list the most recent unattached ones.

Nothing saved yet comes back as `{ found: false, note }` - it means
nobody has researched this, not that the lookup failed.

## Keywords and rankings

The tracking set is separate from one-off research: what you track here
is what the nightly rank cron checks going forward.

### `track_keywords`

Upserts keywords into the tracking set, matched by keyword text. The
rank cron checks every tracked keyword daily while it ranks in the top
30, plus a weekly full-depth sweep for everything else. Fields you omit
are left as-is on existing rows.

<Fields>
  <Field name="keywords" type="array" required="true">
    One object per keyword: `keyword` (required), `volume`, `kd`, `cpc`,
    `intent` (all optional).
  </Field>
</Fields>

Returns `{ upserted, keywords }`.

### `get_rankings`

Rank history for tracked keywords over the last N days. Each keyword
comes back with its current position, its earliest position in the
window, and the change between them (positive means it improved, moving
toward #1).

<Callout type="warning" title="Read checked and position together">
`checked:true` with `position:null` means confirmed not in the top 100.
`checked:false` means the keyword was never successfully checked in this
window - unknown, not "not ranking".
</Callout>

<Fields>
  <Field name="keyword" type="string">
    Scope to one keyword instead of every tracked one.
  </Field>
  <Field name="days" type="number">
    Window size. Defaults to 30.
  </Field>
</Fields>

Returns each keyword with its position history, best-position-first;
keywords outside the top 100 are ordered last, by search volume, so the
tail reads as an opportunity list.

## Published pages

### `log_page`

Records a published page, matched by url (re-logging updates it). Call
this right after a PR opens, not after it merges - the system already
treats a freshly logged page as "awaiting publish" until its url first
serves HTTP 200, which is verified automatically.

<Fields>
  <Field name="url" type="string" required="true">
    Must be on this project's own domain.
  </Field>
  <Field name="title" type="string">
    Page title.
  </Field>
  <Field name="type" type="string">
    `guide`, `tool`, or `landing`.
  </Field>
  <Field name="primary_keyword" type="string">
    The page's main target keyword.
  </Field>
  <Field name="pr_url" type="string">
    The PR that shipped this page.
  </Field>
  <Field name="published_at" type="string">
    ISO date/datetime. Only pass this when backfilling a page that
    published earlier - omit it for a page shipping right now. The
    daily publishing pace reads this field, so a backfill stamped "now"
    wrongly eats today's build slot.
  </Field>
</Fields>

Returns the upserted page row.

### `get_pages`

Lists every published page. Call this before writing new content, to
pick 2-3 existing pages to link to and to avoid covering a topic twice.
Takes no parameters.

Each row carries `live:true|false` - `false` means the page was logged
(its PR opened) but its url hasn't served 200 yet. Don't link to
not-yet-live pages.

### `mark_indexing_requested`

Reports the outcome of a Search Console "Request indexing" browser
session, so the dashboard's Get-it-on-Google card clears itself. Urls
must match already-logged pages (`get_pages`); anything left over (daily
quota hit, login wall) just stays on the card - omit it rather than
guessing.

<Fields>
  <Field name="requested_urls" type="array">
    Pages you clicked Request indexing for.
  </Field>
  <Field name="already_indexed_urls" type="array">
    Pages the inspection showed were already on Google.
  </Field>
</Fields>

Returns `{ requested_marked, already_indexed_marked, unknown_urls }` -
`unknown_urls` is any url that matched no logged page, so it wasn't
actually cleared.

## Traffic and stats

### `get_site_stats`

Google Search Console snapshots for the last N days, newest first, plus
a trend summary (totals and first-half vs second-half deltas for clicks
and impressions). For the combined dashboard-style view (traffic,
rankings, and per-page numbers in one call), see `get_overview` under
Dashboard parity instead.

<Fields>
  <Field name="days" type="number">
    Window size. Defaults to 28.
  </Field>
</Fields>

Returns `{ summary, snapshots }`, plus a `note` explaining an empty
window (Search Console not connected yet, access granted but no sync
yet, or an unreachable property) instead of letting zeros read as a real
traffic story.

## Backlinks

Backlink prospects (domains worth pursuing) and the curated backlink
playbook (directories and paid placements) live together here - both
feed the same Backlinks screen on the dashboard.

### `add_backlink_prospect`

Adds a domain worth pursuing a link from, status `new`. Deduplicates by
normalized domain (case-insensitive, `www.` stripped) - a repeat call
returns the existing row with `already_queued:true` instead of creating
a second one.

<Fields>
  <Field name="domain" type="string" required="true">
    The prospect's domain.
  </Field>
  <Field name="url" type="string">
    The specific page worth targeting, if you have one.
  </Field>
  <Field name="reason" type="string">
    Why it's relevant, or where it was found.
  </Field>
  <Field name="domain_rating" type="number">
    The prospect's own DR, if known.
  </Field>
</Fields>

Returns the new row, or the existing one with `already_queued:true`.

### `get_backlink_prospects`

Lists backlink prospects, newest first.

<Fields>
  <Field name="status" type="string">
    `new`, `contacted`, `acquired`, or `rejected`.
  </Field>
</Fields>

### `update_backlink_prospect`

Moves a prospect through its pipeline: `new` to `contacted` to
`acquired` (or `rejected`). Call it after outreach actually happened or a
link actually went live.

<Fields>
  <Field name="id" type="uuid" required="true">
    The prospect to update.
  </Field>
  <Field name="status" type="string" required="true">
    `new`, `contacted`, `acquired`, or `rejected`.
  </Field>
  <Field name="live_url" type="string">
    The page the acquired link actually lives on (the published guest
    post, the resource-list entry). Set it when marking `acquired` -
    `check_backlinks` and the daily verification can only confirm a link
    they know the address of.
  </Field>
</Fields>

### `check_backlinks`

Verifies every `acquired` prospect right now: fetches each prospect's
page (`live_url`, else `url`), confirms a link to the project's domain
is present, reads its `rel` (follow vs `nofollow`/`sponsored`/`ugc`)
and whether the page is noindexed, and writes the result onto the
prospect rows the Backlinks screen shows. The same check runs daily on
its own; call this after marking a prospect acquired to confirm the
link immediately, or when the owner asks whether their links are still
live.

A link is only ever reported `lost` on a definitive answer - the page
served without the link, or is affirmatively gone (4xx). Timeouts and
5xx responses report `unreachable` and change nothing, and a link never
seen live reads `not_found_yet` rather than lost.

Takes no input. Returns `{ checked, live, lost, neutered, results }` -
`neutered` counts links that exist but carry `nofollow`/`sponsored`/
`ugc` - or `{ skipped }` when no acquired prospect has a URL to check.

### `get_playbook`

The curated backlink playbook: free directories and ROI-ranked paid
placements, with this project's done/skipped progress. Without a slug
you get the compact list; with one, the full submission brief -
prefilled field copy personalized from the site profile, plain-English
steps, gotchas, and a paste-ready `@browser` command.

<Fields>
  <Field name="slug" type="string">
    An item's slug (from the compact list) for its full brief.
  </Field>
</Fields>

### `set_playbook_status`

Marks a playbook item `todo`, `done`, or `skipped` - the same checkbox
as the dashboard's Backlinks screen. Call it after a submission actually
went through (e.g. the `@browser` session finished), not before.

<Fields>
  <Field name="slug" type="string" required="true">
    The playbook item (from `get_playbook`).
  </Field>
  <Field name="status" type="string" required="true">
    `todo`, `done`, or `skipped`.
  </Field>
</Fields>

## Research primitives

Two of these work in every mode with no credentials
(`suggest_keywords`, `check_serp` when a free SerpApi key is connected);
the rest need a connected SERP/DataForSEO account of some kind - your own,
or the platform's bundled plan on cloud.

### `free_serp_scan`

Live, key-free research for one keyword using the built-in scraper:
Google autocomplete expansions, question-shaped queries derived from
them, the organic top 10 parsed from Bing as a Google proxy, detected
SERP features, and a rule-based intent read. Answers on every project,
including GSC-only free mode - it is how the free tier builds a real
intent contract instead of guessing. Carries no volumes and no
difficulty scores; `keyword_ideas` is the pricing tool.

<Fields>
  <Field name="keyword" type="string" required="true">
    The search term to scan, exactly as a user would type it.
  </Field>
  <Field name="serp_features" type="boolean">
    Also detect SERP features. Default true.
  </Field>
</Fields>

### `paa_tree`

Recursively expands one keyword into the tree of real questions people
ask around it, derived live from Google autocomplete. The questions are
drafting material: headings and FAQ entries in the searcher's own words,
which is what answer engines cite. Free on every project. Default caps
(~12 questions, depth 2) keep a call to a few seconds.

<Fields>
  <Field name="keyword" type="string" required="true">
    The seed topic to expand into questions.
  </Field>
  <Field name="max_depth" type="number">Levels to expand, 1-3 (default 2).</Field>
  <Field name="max_children" type="number">Questions per node, 1-4 (default 3).</Field>
  <Field name="total_cap" type="number">Hard cap on total questions, 3-40 (default 12).</Field>
</Fields>

### `check_serp`

Fetches live Google organic results for a keyword through the project's
connected SERP provider, billed to the project's own account or quota.
Use it to judge winnability before proposing content - page 1 full of
Reddit threads and thin posts is winnable, page 1 full of big brands
isn't. Unavailable in GSC-only mode.

<Callout type="warning" title="Rate-capped on the bundled cloud plan">
30 checks/day when billed to the platform's shared DataForSEO account,
resetting at UTC midnight. Own-account and self-host projects aren't
affected. Use `track_keywords` for anything worth ongoing monitoring
instead of repeated ad-hoc checks - the rank cron checks tracked
keywords daily for free. SerpApi's own free tier is separately capped at
250 searches/month.
</Callout>

<Fields>
  <Field name="keyword" type="string" required="true">
    The keyword to check.
  </Field>
  <Field name="top" type="number">
    How many results to return, up to 100. Defaults to 10.
  </Field>
</Fields>

Returns `{ keyword, source, results, ai_overview }` - each result has
position, title, url, and domain.

### `get_domain_rank`

The site's cached Domain Rating snapshot (0-100 DR-equivalent, referring
domains, backlinks, spam score), refreshed weekly by a cron - never a
live paid call. Takes no parameters. A `null` dr means the domain isn't
indexed yet, or the cron hasn't run its first pass for this project.

### `get_dataforseo_usage`

This project's DataForSEO billing status: who it's billed to, month-to-
date spend against the platform's monthly budget (when applicable), the
projected month-end spend under the rank cron's current pacing, and
today's `check_serp` and `keyword_ideas` counts against their daily caps.
Takes no parameters. A project on its own account (or self-host) isn't
metered here - the spend fields stay zero.

The two daily caps exist for different reasons and behave differently.
`check_serp`'s is 30 and applies only to projects on the shared plan -
it stops our account being used as a free SERP proxy. `keyword_ideas`'s
is 10 and applies to **every** project, because one call there is worth
up to ten metered requests and a looping client can empty an account
that isn't ours to protect but is still someone's. Both reset at UTC
midnight and both refuse by name rather than returning an empty result,
so a caller that hits one stops instead of retrying.

### `suggest_keywords`

Expands a seed keyword into related searches via Google Autocomplete -
free, works in every mode, no credentials needed. Returns real queries
people type, without volume numbers. Pair it with `get_site_stats`'s top
queries for seeds, then `check_serp` on the shortlist to judge
winnability.

<Fields>
  <Field name="seed" type="string" required="true">
    The seed keyword to expand.
  </Field>
  <Field name="modifiers" type="array">
    Up to 8 modifier words to combine with the seed.
  </Field>
</Fields>

### `keyword_ideas`

Expands seed keywords into related searches with real monthly search
volume and keyword difficulty, via DataForSEO. Works whenever the
project has DataForSEO access - its own account, or the platform's
bundled plan on cloud - so it's how bundled-plan projects get the
numbers the quality bar gates on. Queries the project's configured
search market (US/English unless changed via `set_market` or Settings),
so a Hebrew site set to Israel gets Hebrew keywords with Israeli
volumes. Send seeds best-first: only the first 5 are expanded, at 2
metered DataForSEO calls each, and the response note names any skipped.

<Fields>
  <Field name="seeds" type="array" required="true">
    1 to 20 seed keywords.
  </Field>
  <Field name="limit" type="number">
    Max ideas returned, 1 to 200. Defaults to 100.
  </Field>
</Fields>

Returns `{ seeds, ideas }`, sorted by volume, highest first. Returns an
empty `ideas` list with a `note` (never an error) when the project has
no DataForSEO access or its monthly budget is spent - fall back to
`check_serp` and product judgment then.

## AI visibility

How AI answer engines see this site - the GEO half of the product.

### `get_ai_visibility`

Per-engine summary (queries checked, AI answers seen, citation rate), a
per-day trend, the latest verbatim answers, and a gap list of domains AI
cites on queries where this site isn't. Takes no parameters. Google AI
Overview data arrives automatically from the weekly rank sweep; the
other engines fill in only when a geo-scan workflow runs. Use the gap
list to pick what to write next.

### `record_ai_citations`

Writes geo-scan results: for each query asked of an AI answer engine,
whether an answer came back, whether it cited this site, and every
source it cited. Called by the geo-scan workflow after sampling each
query - `google_ai_overview` is cron-only and can't be written here.

<Fields>
  <Field name="results" type="array" required="true">
    1 to 100 entries. Each has: `engine` (`claude`, `chatgpt`,
    `perplexity`, or `gemini`, required), `query` (required),
    `has_ai_answer` (required), `cited` (required), `cited_url`
    (optional), `answer_excerpt` (optional - a short verbatim quote so
    the owner can read the real answer behind the number), and
    `citations` (optional, up to 30 `{ domain, url, title }` sources).
  </Field>
</Fields>

Returns `{ recorded }`, the count written.

## Trends

The Trend radar is two stages: a scan finds trending subjects, then the
owner picks one to expand into guide-idea takes.

### `record_trend_scan`

Stamps the project's `last_trend_scan_at` with now. Takes no
parameters. Called by the trend-scan workflow itself at the end of every
run, found something or not - it backs the Trend radar's "Scan now"
cooldown, not something the owner calls directly.

### `propose_trend_topic`

Puts a trending subject on the Trend radar - a conversation the niche is
having right now, not a guide idea. Called by the trend-scan workflow,
once per shortlisted subject (up to 5 per scan). Deduplicates by title
across every status - if the subject is already on the radar, the
existing row comes back with a note instead of a duplicate.

<Fields>
  <Field name="title" type="string" required="true">
    The trending subject.
  </Field>
  <Field name="why_now" type="string" required="true">
    The trigger event and its date, leading the explanation.
  </Field>
  <Field name="signals" type="array">
    Up to 8 threads/launches/trend lines actually seen.
  </Field>
  <Field name="sources" type="array">
    Up to 8 vendor posts or threads that prove it.
  </Field>
  <Field name="seed_url" type="string">
    The single most viral piece of content driving the subject - the
    builder later writes from it directly (credit, quotes, embed).
  </Field>
  <Field name="seed_stats" type="string">
    That content's public numbers and date, e.g. "512k views, Jul 12".
  </Field>
</Fields>

### `get_trend_topics`

Lists radar subjects, newest first.

<Fields>
  <Field name="status" type="string">
    `new`, `expanding`, `expanded`, or `dismissed`.
  </Field>
</Fields>

### `update_trend_topic`

Moves a subject to `expanded` (the trend-expand workflow's last step) or
`dismissed` (housekeeping for subjects older than 14 days, or the owner
passing on one). `expanding` isn't settable here - that's what
`expand_trend_topic` does, since picking a subject to expand is the
owner's move.

<Fields>
  <Field name="id" type="uuid" required="true">
    The subject to update.
  </Field>
  <Field name="status" type="string" required="true">
    `expanded` or `dismissed` only.
  </Field>
</Fields>

### `trigger_trend_scan`

<Pill tone="amber">Owner only</Pill>

The Trend radar's "Scan now" button. Wakes the project repo's trend-scan
workflow; subjects appear on the radar (`get_trend_topics`) a few
minutes later. Shares the dashboard's 30-minute cooldown - a refusal
means the radar is already current, don't retry. Only use this when the
site owner asked for a scan in the current conversation; the scan
workflow itself must never call this (it reports back through
`record_trend_scan` instead). Takes no parameters.

### `expand_trend_topic`

<Pill tone="amber">Owner only</Pill>

The radar's "Get takes" button. Wakes the trend-expand workflow for one
subject; its takes land in the suggestions queue a few minutes later,
waiting `pending` like every trend idea. Refuses dismissed subjects and
repeats within a 30-minute cooldown (per subject) - a refusal means
takes are already on their way. Only use this when the site owner picked
the subject in the current conversation.

<Fields>
  <Field name="id" type="uuid" required="true">
    The radar subject to expand.
  </Field>
</Fields>

## Site config and profile

### `get_site_profile`

Reads the site profile the backlink playbook personalizes from - name,
tagline, descriptions, categories, tags. Takes no parameters. Returns
`null` if `/seo-setup` hasn't written it yet.

### `set_site_profile`

Writes the site profile that prefills every directory submission and
`@browser` command. Called by the `/seo-setup` command after researching
the product.

<Fields>
  <Field name="name" type="string" required="true">
    The site's name.
  </Field>
  <Field name="url" type="string" required="true">
    Must be a valid url.
  </Field>
  <Field name="tagline" type="string" required="true">
    Up to 60 characters.
  </Field>
  <Field name="short_description" type="string" required="true">
    Up to 160 characters.
  </Field>
  <Field name="long_description" type="string" required="true">
    300-600 characters is the target directories in the playbook
    expect (the field itself accepts 100-700).
  </Field>
  <Field name="categories" type="array" required="true">
    1 to 5 categories.
  </Field>
  <Field name="tags" type="array" required="true">
    1 to 10 tags.
  </Field>
</Fields>

### `set_gsc_property`

Corrects which Google Search Console property this project tracks.
Onboarding guesses `sc-domain:<domain>`, but plenty of real properties
are URL-prefix (`https://example.com/`) - if GSC data never arrives and
access checks keep failing, the guess was probably wrong.

<Fields>
  <Field name="site_url" type="string" required="true">
    The property exactly as Search Console names it.
  </Field>
</Fields>

### `detect_site_launch`

Finds evidence of when the site actually went live - Search Console's
earliest impression date (Google keeps ~16 months of history, so for
older sites this is a floor, flagged `at_least: true`) and the Wayback
Machine's first capture - and moves `site_launched_at` backward to the
earlier of the two. Never moves the date forward and never overrides an
owner's earlier correction. The launch date drives the Journey stage,
publishing pace and research difficulty posture, so run this when it
looks like the day the project joined DispatchSEO rather than the day
the site launched. Takes no arguments.

### `set_market`

Sets which Google country and language the project's rank checks and
keyword research query. Every project starts at United States / English,
which quietly measures the wrong Google for a site whose audience
searches from another country or in another language. Same write as the
Settings page's Search market row. The tool description lists every
supported market with its location code and languages.

<Fields>
  <Field name="location_code" type="number" required="true">
    DataForSEO location code, e.g. `2840` = United States, `2376` =
    Israel.
  </Field>
  <Field name="language_code" type="string" required="true">
    A language the chosen market supports, e.g. `en`, `he`, `de`.
  </Field>
</Fields>

### `set_publish_hour`

Sets the hour when this project's finished articles go live - a whole
hour in UTC, 0-23 (default 9). Writing can happen at any time; an
accepted article always waits for the next occurrence of this slot, so
the site publishes at a consistent editorial hour instead of whenever a
build happened to finish. Changes apply to articles that finish after
the change. Same write as the dashboard's Settings > Publish time row.

<Fields>
  <Field name="hour" type="number" required="true">
    Whole hour in UTC, 0-23.
  </Field>
</Fields>

### `disconnect_repo`

Stops DispatchSEO running in this project's repo: the `seo-*` workflows are
disabled and deleted, the `.dispatchseo` files and the `SEO_MCP_API_KEY`
secret are removed, and the connection is cleared. Schedules stop, so the
repo stops spending the owner's GitHub Actions minutes. Published guides,
tools and pages are never touched, and the project keeps its keywords,
rankings and history - reconnecting later re-installs the pipeline. If the
repo can't be reached the connection is **kept**, so the attempt can be
retried rather than leaving a project pointing at a half-cleaned repo.
Deleting a project deliberately has no tool here; disconnecting is the
reversible half.

<Fields>
  <Field name="confirm" type="string" required="true">
    The connected repo as `owner/name`, typed exactly. Anything else is
    refused - it guards against a disconnect nobody asked for.
  </Field>
</Fields>

Returns `{ disconnected, workflows_disabled, files_removed, secret_removed,
note }`.

### `set_github_repo`

Points the project at the GitHub repository its content pipeline lives
in. On cloud it's validated against the project's GitHub App
installation's live repo list - a repo outside the installation is
refused. On self-host it's validated against the instance's stored
GitHub token when one exists (the token must see the repo and read its
code); before that token is saved, the write is accepted on format
alone, same as project creation. Changing an already-connected repo
never touches the old repo - if the pipeline was installed there, run
`disconnect_repo` first so its workflows stop.

<Fields>
  <Field name="repo" type="string" required="true">
    `owner/repo` (a github.com URL works too). On cloud it must be part
    of this project's App installation.
  </Field>
</Fields>

### `set_agent`

Chooses which coding agent runs this project's **unattended** builders -
the scheduled GitHub Actions, or the in-stack container on a self-hosted
install. It does not change which agent *you* are: whatever agent is
connected over MCP right now keeps driving everything interactively
regardless of this setting. It takes effect on the next scheduled run
with no repo change, because the workflow files carry every agent and
ask which to use at run time. The reply names anything the owner still
has to do - chiefly adding the new agent's credential where the builders
run. Ask the owner before switching: the agents bill differently (a
Claude or Cursor subscription vs metered OpenAI usage), so it's their
call.

<Fields>
  <Field name="agent" type="string" required="true">
    `claude` (Claude Code, runs on a Claude subscription), `codex`
    (Codex, metered against an OpenAI API key), or `cursor` (Cursor,
    runs on your Cursor plan's API key).
  </Field>
</Fields>

### `set_internal_linking`

<Pill tone="amber">Owner only</Pill>

The only door to internal back-linking. With it on, the guide builder edits
2-3 of the closest already-published posts in the same PR so they link to
each new guide - one sentence changed per post, nothing else touched - which
is what turns a set of posts into a cluster that compounds. This is the only
DispatchSEO behaviour that modifies pages the owner already published, so
it's off by default, kept separate from the semi/auto automation flags, and a
PR that edits published posts is never auto-merged regardless of
`auto_merge`. Ask the owner before turning it on - never enable it on your
own initiative because it would make a run tidier.

<Fields>
  <Field name="enabled" type="boolean" required="true">
    `true` to allow editing published posts, `false` to stop.
  </Field>
</Fields>

Returns `{ project, internal_linking, note }`. On a database that hasn't run
migration `0045_project_internal_linking.sql` yet the call fails, naming that
migration.

### `set_managed_writing`

<Pill tone="amber">Owner only</Pill>

Turns managed writing on or off. When ON, Pulse's own server-side writer
researches and writes each approved guide idea - at most one article a day,
through the same publishing gate every other writer walks - so the owner
needs no connected AI for the daily article. When it is on, do **not** also
write and submit the day's guide yourself: the queue would produce
duplicates. Research, backlinks and every other workflow still belong to
you. Ask the owner before changing this - it decides whose account pays for
the writing.

<Fields>
  <Field name="enabled" type="boolean" required="true">
    `true` for Pulse's server-side writer, `false` for bring-your-own AI.
  </Field>
</Fields>

Returns `{ project, managed_writing, platform_key_configured, note }` -
`platform_key_configured` false means the deploy has no `ANTHROPIC_API_KEY`
yet, so nothing will be written until the operator sets one. On a database
that hasn't run migration `0065_managed_writing.sql` the call fails, naming
that migration.

### `get_project`

The project this token belongs to and how it's set up: domain, mode,
the effective auto-approve flags, keyword source, whether a SERP
provider and Search Console are connected, whether this repo has its own
DataForSEO MCP server, the content-pipeline repo, whether one-tap merge
is available, where finished articles go (`publish_target`: github,
wordpress, api or manual), whether WordPress is actually connected
(`wordpress_connected`), whether the Content API's delivery key exists
(`content_api_configured`, presence only - the key itself is
dashboard-only) and its `blog_base_path`, plus the setup wizard's answers - `ai_choice`
(what the owner said will write: claude-web, chatgpt, claude-code,
codex, cursor, or null for projects from before the adaptive wizard) and
`chat_app_connected` (true once any request has reached this server
from a chat app). Takes no parameters. Worth calling first in a session,
since it tells you which of the other tools will actually work. Secrets
are never returned - credentials stay dashboard-only.

## Dashboard parity

These read tools call the exact same modules the dashboard's screens
render from, so the two views can never drift apart.

### `get_overview`

The dashboard Home/Analytics view in one call: 28-day traffic totals,
live last-24h numbers, domain rating, the keyword ranking table, top
search queries, and per-page traffic for every built guide and tool -
plus the journey (which SEO stage the site is in, what to expect next)
and this week's real movement. Takes no parameters. Start here for
"what's going on with my SEO" - it's the whole picture. For raw daily
GSC snapshots use `get_site_stats`; for rank history use `get_rankings`.

### `get_briefing`

The dispatcher's briefing - the card at the top of Home, in the agent's
own first-person voice. Takes no parameters. Returns the day's clicks and
impressions, what the pipeline is building right now, and `wins`: the
shortlist of things worth acting on today, read out of Search Console
using the signals the big SEO tools lead with - striking-distance queries
(position 8-20, one push from page one), page-one queries that rank but
don't get clicked, high-impression zero-click queries, rising queries,
searches the site has newly started appearing for, and first-ever
milestones (each carrying its real-world base rate, so a first top-10
can be sized, not just checked off). `action` is today's one hands-on
move - usually the next free backlink playbook listing, raised only when
the profile is genuinely thin or stalled - and is `null` most days.
Call it for "what should I do about SEO today". An empty
`wins` list is a real answer on a young site; `patience` says why, and
inventing a win to fill the gap defeats the point of the tool.

### `get_activity`

What the SEO manager has been doing. Takes no parameters. Returns
`today` (a granular checklist since UTC midnight, each publish/approval
named) and `week` (the last 7 days, aggregated into counts).

### `get_automations`

The automations registry: what runs on its own, on what schedule, and an
evidence line per automation (last run, last snapshot, last build) drawn
from the data it actually writes. Takes no parameters. Answers "is the
nightly rank check running?" or "when does the guide builder fire?".

### `get_cron_health`

The latest run of this project's background jobs - deploy-check, the SEO
GitHub workflows, the secrets canary, and (self-host only) the
platform's own instance-wide crons too. Takes no parameters. Each entry
carries ok/failed, error strings, and whether the job is stale. An empty
result means no job has ever logged a run; an entry with
`update_available:true` isn't a failure, just an installed pipeline pack
a version behind. `repeat_failure:true` means the same job also failed
the real run before this one. The owner's dashboard and the alert emails
only surface failures that are repeated, stale, or urgent (a broken
deploy, dead credentials, an empty balance) - a one-off failed run shows
here first and nowhere else, so finding one the dashboard doesn't show
is the design working, not a discrepancy to report.

### `mark_cron_fixed`

Clears a background-job alert after you've actually fixed and verified
the underlying problem - logs a synthetic ok run for that job. Call this
only after re-running the workflow (or hitting the endpoint) and seeing
it succeed; if the problem persists, the next real failure or missed
window re-raises the alert on its own.

<Fields>
  <Field name="job" type="string" required="true">
    The exact job name from `get_cron_health`, including any
    `--<project>` suffix. Fails if that job has no active alert.
  </Field>
</Fields>

### `get_next_actions`

Everything waiting on a human decision: suggestions awaiting approval,
approved items waiting for their build, builds in progress, open SEO PRs
ready to merge, and pages waiting for a Search Console "Request
indexing" click. Takes no parameters.

Returns `{ awaiting_approval, approved_waiting_build, building_now,
open_seo_prs, indexing_queue, backlink_move }` - the indexing queue comes
with a paste-ready `@browser` command; report the outcome with
`mark_indexing_requested`. `backlink_move` is the links half of the job
kept specific: when the site's backlink profile is thin (under ~5
referring domains) or measured-flat for a month, it names the one free
playbook listing to action next (mark it with `set_playbook_status` once
actually submitted); it is `null` while the profile is healthy and
growing.

### `merge_pr`

Squash-merges an open SEO PR on the project's repo - the dashboard's
one-tap merge. Only merge PRs listed by `get_next_actions`, and only
when the user asked for it or its checks are green. Requires the
server's merge token to be configured; without it, this fails and the
PR page link is the fallback.

<Fields>
  <Field name="number" type="number" required="true">
    The PR number to merge.
  </Field>
</Fields>

### `get_changelog`

What shipped in DispatchSEO itself, newest first - the same list the
dashboard shows at `/changelog`. This is about the product, not your
site's own activity (for that, use `get_activity`).

<Fields>
  <Field name="limit" type="number">
    Trims the list, 1 to 50. Defaults to 10.
  </Field>
</Fields>

### `get_feedback`

Feature requests people have asked for in DispatchSEO itself, most-voted
first - the same board the dashboard shows at `/feedback`. Like
`get_changelog`, this is about the product, not your site. The board is
shared, not per-project: the token you connect with only says *who* is
asking, which is what one-vote-per-account is counted by. Each request comes
back with its vote count, whether this account already voted for it, and
whether this account wrote it.

<Fields>
  <Field name="limit" type="number">
    How many requests to return, 1 to 200. Defaults to 50.
  </Field>
  <Field name="status" type="string">
    `open`, `planned`, `in_progress`, `shipped`, or `declined`.
  </Field>
</Fields>

Returns `{ dashboard_url, moderator, total, requests }` - `moderator:true`
means this account is the one that can also call `update_feedback`.

### `submit_feedback`

Asks for something in DispatchSEO itself - a feature, a change, a thing
that's missing - posted to the shared board under the account that owns this
project. Check `get_feedback` first: voting for an existing request beats
posting a duplicate. Plain text only, and the rules are enforced rather than
silently cleaned up - links, email addresses and HTML are rejected outright,
with the reason named. An account can post 5 requests a day, and re-sending
the same title within 24 hours comes back as a double-submit rather than a
second row.

<Fields>
  <Field name="title" type="string" required="true">
    The request in one line, 6 to 120 characters.
  </Field>
  <Field name="body" type="string">
    Optional detail: what you're trying to do and what gets in the way. Up to
    1200 characters.
  </Field>
</Fields>

Returns `{ id, title, emailed, dashboard_url }`. `emailed:false` only means
the maintainer's notification didn't send - the request is on the board
either way.

### `vote_feedback`

<Pill tone="violet">Cloud only</Pill>

Adds or removes this account's vote on a request from the board. One vote per
account per request, and setting a vote it already holds is a no-op - safe to
retry, since a retry can't undo the vote it just cast. A vote needs an
account, so a self-hosted deployment gets an error pointing at
`submit_feedback` instead.

<Fields>
  <Field name="id" type="string" required="true">
    The request's id, from `get_feedback`.
  </Field>
  <Field name="vote" type="boolean">
    `true` to vote (the default), `false` to take the vote back.
  </Field>
</Fields>

Returns `{ id, voted, votes }` - the settled state and the new count.

### `update_feedback`

<Pill tone="amber">Moderator only</Pill>

Sets a request's status or hides it from the board. Fails for everyone else;
`get_feedback`'s `moderator` field is what tells you whether this account
qualifies. Hiding never deletes - the request stays in the database and only
drops off the board. Pass `status`, `hidden`, or both; passing neither is an
error.

<Fields>
  <Field name="id" type="string" required="true">
    The request's id, from `get_feedback`.
  </Field>
  <Field name="status" type="string">
    `open`, `planned`, `in_progress`, `shipped`, or `declined`.
  </Field>
  <Field name="hidden" type="boolean">
    `true` hides it from the board, `false` puts it back.
  </Field>
</Fields>

Returns `{ id, status, hidden, updated }`.

## Instructions and install

The install/setup pipeline is served as content, not hardcoded into any
repo - so an instructions update reaches every connected project's next
run without touching a single user repo.

### `get_instructions`

The operating instructions for one SEO workflow, personalized to this
project. Automations and agents must call this before running a
workflow and follow the returned markdown exactly - it's the live
version of the playbook.

<Fields>
  <Field name="workflow" type="string" required="true">
    `install`, `setup`, `research`, `trend-scan`, `trend-expand`,
    `build-guide`, `build-tool`, `report`, `backlinks`, or `geo-scan`.
  </Field>
</Fields>

Returns `{ project, version, workflow, summary, markdown }`. The
`project` field names who this token belongs to - confirm it matches
the site you mean to operate on before following the playbook, since a
mismatched token would act on another site's data. Site-specific facts
live in the repo's `.dispatchseo/conventions.md`, which the setup
workflow writes.

### `get_pipeline_pack`

The repo-side shim files (GitHub workflows, MCP configs, slash
commands), personalized to this project. Call it with no arguments for
the manifest - a list of file paths only, since the full pack overflows
one response - then call it again with a path to get that one file's
content, writing each one at a time.

<Fields>
  <Field name="path" type="string">
    A path from the manifest. Omit for the manifest itself.
  </Field>
</Fields>

### `mark_install_step`

A progress ticker for the owner's wizard finale - stamp one step the
moment you finish it, so the checklist the owner is watching ticks in
real time instead of sitting dark for 20-60 minutes. Purely
informational: it unlocks nothing (`mark_pipeline_installed` does that),
and a failure here must never stop the install.

<Fields>
  <Field name="step" type="string" required="true">
    `workflows`, `adaptation`, `repo_settings`, `content_home`,
    `site_facts`, or `research`. Call once per step, right after
    finishing it.
  </Field>
</Fields>

### `mark_pipeline_installed`

Stamps this project as pipeline-installed. Called once, by the install
workflow's final step, only after its own verification checklist
passes. Takes no parameters. This call unlocks the owner's dashboard, so
it fails loudly - with the specific problem named - rather than
stamping an unverified install.

### `set_conventions`

Mirrors the repo's `.dispatchseo/conventions.md` site facts to the
backend, so the dashboard's Instructions page can show how DispatchSEO
adapted to this site. Called by the setup workflow right after writing
the repo file, with the complete current facts - this is a full
replace, not a patch.

<Fields>
  <Field name="product_summary" type="string">
    What the site/product is.
  </Field>
  <Field name="stack" type="string">
    The repo's tech stack.
  </Field>
  <Field name="package_manager" type="string">
    e.g. `pnpm`, `npm`.
  </Field>
  <Field name="build_command" type="string">
    How to build/typecheck the repo.
  </Field>
  <Field name="guides_dir" type="string">
    Where guide content lives.
  </Field>
  <Field name="tools_wiring" type="string">
    How a new tool page gets wired in.
  </Field>
  <Field name="theme_tokens" type="array">
    `{ name, value }` pairs - include resolved color values (hex/oklch)
    where the token is a color, so the dashboard can render real
    swatches.
  </Field>
  <Field name="fonts" type="array">
    Font names in use.
  </Field>
  <Field name="voice_rules" type="array">
    Style/voice rules content should follow.
  </Field>
  <Field name="exemplar_guides" type="array">
    Example guide urls or paths worth imitating.
  </Field>
  <Field name="exemplar_visuals" type="array">
    Example visual references.
  </Field>
  <Field name="tool_reference" type="string">
    An existing tool page worth using as a pattern.
  </Field>
  <Field name="analytics" type="string">
    How analytics is wired on the site.
  </Field>
  <Field name="notes" type="string">
    Anything else worth recording.
  </Field>
</Fields>

### `get_conventions`

The site facts last mirrored via `set_conventions`, with `updated_at`.
Takes no parameters. `null` data means the setup workflow hasn't run
yet - the repo's own `.dispatchseo/conventions.md` remains the
agent-facing source of truth; this copy exists for the dashboard and for
agents working without the repo checked out.

## Content preferences

### `get_content_prefs`

The owner's template controls from the dashboard's Instructions page:
`house_rules` (free text injected into every build), `disabled_archetypes`
(guide shapes removed from rotation), and `disabled_blocks` (skeleton
parts dropped). Takes no parameters. Build workflows don't need to call
this directly - the same preferences are already rendered into
`get_instructions`.

### `set_content_prefs`

<Pill tone="amber">Owner only</Pill>

Changes the owner's template controls. Use this only when the owner
asked for the change in the current conversation - an autonomous build
run must never adjust its own content preferences. Provided fields
replace their current value wholesale; fields you omit keep their
current value.

<Fields>
  <Field name="house_rules" type="string">
    Up to 2000 characters, injected into every build.
  </Field>
  <Field name="disabled_archetypes" type="array">
    Any of `tutorial`, `comparison`, `data-study`, `opinion`,
    `reference`. At least 2 must stay enabled.
  </Field>
  <Field name="disabled_blocks" type="array">
    Any of `cover`, `tldr`, `comparison_table`, `visuals`, `faq`. All are
    on by default - `cover` drops the generated cover image, so guides
    ship with no `cover` frontmatter and the blog card falls back to its
    own plate.
  </Field>
</Fields>

### `join_waitlist`

The odd one out in this section - it doesn't touch content preferences
at all, it's just the last tool registered on the server. Adds an email
to the DispatchSEO Cloud waitlist, the same list the public landing page
feeds. Duplicate emails are fine; re-joining is a no-op, not an error.

<Fields>
  <Field name="email" type="string" required="true">
    The email address to add.
  </Field>
</Fields>
