Local SEO MCP tools: business profile, reviews, updates, categories, rank grid (#489)

This commit is contained in:
Ben Senescu 2026-08-17 23:21:34 -04:00 committed by GitHub
parent 052977f20d
commit 7738f72333
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2851 additions and 1625 deletions

View File

@ -10,6 +10,7 @@ data, or sensitive paths.
## Open
- [ ] `2026-08-18T03:06:44Z``claude` — Changing an MCP tool's `outputSchema` while the dev server hot-reloads makes in-flight MCP sessions reject the tool's own (already billed) results — clients validate against the schema cached at connect time, surfacing as "must NOT have additional properties". Note in the MCP dev docs/skill: reconnect the MCP session after any output-schema change before re-testing live.
- [ ] `2026-08-05T20:59:09Z``codex` — The documented `pnpm seed:rank-tracking` command fails before opening local D1 because `scripts/seed-rank-tracking.ts` imports the provider-aware `src/db/schema` barrel and plain `tsx` cannot load the resulting `cloudflare:workers` URL. Keep the seed script on dialect-local schema imports or run it through a Workers-compatible execution path. (Workaround: seed via raw SQL with `wrangler d1 execute DB --local`.)
- [ ] `2026-08-01T16:28:36Z``claude` — web's pinned wrangler 4.71.0 fails `kv namespace create` with a bare "Authentication error [code: 10000]" even though the OAuth token has workers_kv write scope; wrangler@4.118.0 succeeds with identical auth. Fix: bump wrangler in web/package.json.
- [ ] `2026-07-20T20:08:28Z``claude` — In a fresh git worktree, `oxlint --type-aware` crashes with `Cannot find module '@oxlint/binding-darwin-arm64'` — the platform-specific optional dep is missing from the worktree's node_modules while tsc/prettier work fine, and plain `pnpm install` reports up-to-date without restoring it; `pnpm install --force` (~22s) fixes it. Worth making the worktree-setup hook (or a documented step) run the forced install so lint doesn't die on fresh worktrees.

View File

@ -0,0 +1,57 @@
---
name: create-repo-skill
description: Create or update a skill in this repository the right way — canonical home in .agents/skills, internal-vs-public marking, symlink mirroring into .claude/skills, and public docs registration for product skills. Use whenever adding a new skill, converting a workflow into a skill, or when .claude/skills and .agents/skills look out of sync.
metadata:
internal: true
---
# Create a repo skill
## The layout (invariants)
- **`.agents/skills/<kebab-name>/SKILL.md` is the only canonical home for every skill** — public product skills (SEO workflows customers install) and internal repo skills (agent workflows like `merge-ready`, `papercuts`, this one) alike. Users install from this tree via `npx skills add every-app/open-seo`.
- **`.claude/skills/` contains only symlinks into `.agents/skills/`** — one per skill that Claude Code agents working in this repo should auto-load. Never copy files: `.agents/skills/` is prettier-ignored (vendored skills are hash-pinned) while `.claude/skills/` is not, so a copy gets reformatted on the `.claude` side and the trees drift — this happened to three skills before symlinks became the rule. `prettier --check .` does not descend into the symlinks, so a symlink stays byte-identical to its canonical source by construction.
- **Vendored skills** (external origin) are hash-pinned in `skills-lock.json` (currently only `webapp-testing`, from `anthropics/skills`). Never hand-edit a vendored skill's content; re-vendor with the `skills` CLI so the lock hash stays valid.
- `.agents/skills/**` is part of the **review control plane** (see `AGENTS.md`): changes require explicit maintainer review via CODEOWNERS. Make the change on a branch and call it out in the PR — never treat skill edits as incidental.
## Creating a skill
1. `mkdir .agents/skills/<kebab-name>` and write `SKILL.md` with frontmatter:
```markdown
---
name: <kebab-name> # must match the directory name
description: <what it does + explicit "use when ..." triggers>
metadata:
internal: true # ONLY for internal repo skills — omit for product skills
---
```
2. Decide which kind it is:
- **Internal repo skill** (agent/dev workflow): set `metadata.internal: true`. Do NOT register it on any public surface. If repo agents should auto-load it, add the mirror symlink:
```bash
ln -s ../../.agents/skills/<name> .claude/skills/<name>
```
- **Public product skill** (a customer-facing SEO workflow): no `internal` flag, usually no `.claude/skills` symlink (repo agents don't need customer workflows). Register it everywhere users discover skills:
- `web/content/docs/skills/<name>.mdx` — docs page (mirror a sibling like `competitor-analysis.mdx`: what it does, when to use it, what you get back, how to get the best result)
- `web/content/docs/skills/index.md` — bullet in the right workflow section
- `web/content/docs/skills/meta.json` — nav entry
- `.agents/skills/seo-coach/SKILL.md` — one line in the "What each workflow does" roster
- Optional: `web/src/lib/feature-pages.ts` and `web/content/docs/skills/setup.md` if it deserves marketing/setup placement
3. If the skill references MCP tools, use exact tool names and keep them in sync with `src/server/mcp/server.ts` — the tool names in skills are load-bearing for agents following them.
4. `pnpm format:write` (covers the docs pages; `.agents/skills` itself is intentionally untouched), then commit. Skill prose follows `openseo-review-web-content` standards when public.
## Sync check (run when in doubt, and after any skill change)
```bash
for d in .claude/skills/*/; do n=$(basename "$d")
[ -L "${d%/}" ] || echo "DRIFT RISK — not a symlink: $n"
[ -e ".agents/skills/$n" ] || echo "BROKEN — no canonical source: $n"
done
```
Anything flagged: move the canonical content to `.agents/skills/<name>/` (reconciling differences deliberately — diff both sides first, newest intent wins), delete the `.claude` copy, and replace it with the symlink.

View File

@ -0,0 +1,72 @@
---
name: local-seo
description: "Audit a Google Business Profile, compare it to local competitors, and map Maps visibility around a location."
---
# OpenSEO Local SEO
## Goal
Work out why a business does or does not show up in Google Maps and the local pack near its customers, and what to fix first.
Use this when rankings depend on a physical location or service area. For national organic work, use `competitor-analysis` or `keyword-research`.
## Required inputs
- `projectId`
- The business: name, or a `cid`/`placeId` (most reliable)
- Its coordinate (latitude/longitude) — derive it from a `search_local_businesses` / `get_local_serp_results` row; only ask the user when derivation is ambiguous
- One to three keywords customers actually search (e.g. "emergency plumber", not the brand name)
## OpenSEO MCP tools
- `search_local_businesses`: nearby listings, filterable by `minRating`, `minReviews`, and `isClaimed` — use `isClaimed: false` to find unclaimed listings when prospecting. One call with the brand name as `query` and a wide radius returns category, rating, review count, claimed status, coordinates, and `cid` for every location of a chain — usually enough that per-location `get_business_profile` calls are unnecessary.
- `get_local_serp_results`: the Maps/Local Finder result set near a coordinate. The rows carry `cid` and `place_id` — collect them once and reuse them everywhere below.
- `get_business_profile`: the full profile for one business (hours, rating breakdown) when the `search_local_businesses` row isn't enough.
- `get_business_reviews`: reviews with ratings, text, and whether the owner replied. Queued: a `processing` response returns a `taskId` — call again with it after 30-60 seconds, at no extra cost.
- `get_local_rank_grid`: rank at every point of a grid around a coordinate, with each point's result count and #1 business. 3x3 is nine searches; only go to 5x5 when the service area is genuinely wide.
- `get_google_business_questions`: Q&A on the profile (accepts `cid`/`placeId`).
- `get_business_updates`: posts published on the profile, with dates.
- `list_business_categories`: valid category slugs for `search_local_businesses`.
## Workflow
1. Find the business. Given only a name or website, `search_local_businesses` (name as `query`, wide radius) locates the listing and yields its `cid` and coordinate. If it returns several locations, the business is a chain — see multi-location below.
2. Run `get_local_serp_results` for the main keyword near the business coordinate. Record the top 3-5 competitors' `cid`/`place_id` and the user's own row.
3. Compare the user's listing against the top two competitors: primary category, additional categories, review count, hours completeness, photo count, claimed status. `search_local_businesses` rows usually carry all of this; use `get_business_profile` for what they lack.
4. Sanity-check each listing's website link (`url`/`contact_url` in the rows): it should deep-link to that location's page on the project domain, not a homepage or a stale domain. For broader on-page work, hand off to `run_site_audit`.
5. Call `get_business_reviews` for the user and the strongest competitor. Look at review volume, recency, average rating, and how many reviews got an owner reply.
6. Run `get_local_rank_grid` for the main keyword. Use the grid to separate "ranks at the storefront only" from "ranks across the service area", and each point's `topResult` to name who wins where the target doesn't.
7. Add `get_google_business_questions` and `get_business_updates` when the profile basics are already competitive and the gap is engagement rather than setup.
8. Turn the evidence into a prioritized list. Category and claim problems outrank posting cadence every time.
### Multi-location businesses
Always build the profile snapshot table for the whole chain — one `search_local_businesses` call covers it. The per-location deep-dives (reviews, grid, posts, Q&A) are where cost scales:
- 5 locations or fewer: deep-dive them all.
- More than 5: present the snapshot table, then ask the user (AskUserQuestion) which 1-3 locations to deep-dive. Pick sensible defaults to recommend — e.g. the weakest profile in the densest market.
## Output format
Start with:
- Profile snapshot (category, rating, reviews, claimed) — one row per location for chains
- Where visibility drops off, per the grid
- The one fix to do this week
Then include:
| Signal | This business | Best competitor | Gap | Action |
| ------ | ------------- | --------------- | --- | ------ |
Cover: categories, reviews (count, recency, owner replies), hours and profile completeness, listing website links, Maps coverage from the grid, Q&A and posting hygiene.
## Guardrails
- Do not run a 5x5 grid, or grids for several keywords, without telling the user the cost first — every point is a paid SERP call.
- Match businesses by `cid` or `place_id` when you have one. Name matching collides with chains and similarly named businesses.
- A missing rank at a grid point means the business wasn't among the results returned there. Read it with that point's `resultsCount`: a full result set means outranked; a near-empty one means a sparse SERP, not proof of invisibility.
- Do not infer local-pack strength from national organic metrics.
- Never recommend review gating, fake reviews, or keyword-stuffed business names.
- A grid centered on the wrong place is worse than no grid — confirm the coordinate matches the storefront before spending grid credits.

View File

@ -44,6 +44,7 @@ Good starting points:
- `keyword-clustering`: groups keywords by intent and maps clusters to existing or proposed pages.
- `competitive-landscape`: identifies who wins across a market and what content/backlink patterns are working.
- `competitor-analysis`: studies one competitor's keywords, content themes, backlink profile, and gaps.
- `local-seo`: audits a Google Business Profile against local competitors and maps Maps visibility around a location.
- `link-prospecting`: finds likely link opportunities, discovers contact paths, and drafts outreach.
## Tool coaching

View File

@ -0,0 +1,70 @@
---
name: verify-local-mcp
description: Verify the OpenSEO MCP server end-to-end on a local dev server — protocol-level correctness against real DataForSEO, then a headless-agent consumer probe that tests tool ergonomics (descriptions, schemas, output size, errors, async flows) without the maintainer manually driving an MCP client. Use after adding or changing MCP tools, or when asked to check that the MCP "works" or "is ergonomic".
metadata:
internal: true
---
# Verify local MCP
Two layers, in order. The protocol layer proves the server and provider behave; the consumer layer proves an agent that has never seen the code can use the tools well. They catch different bugs — protocol testing found DataForSEO quirks (zoom-dependent empty SERPs), the consumer probe found ergonomics failures (9KB provider rows overflowing client token budgets, fractional inputs rejected upstream with raw provider errors). Do both.
## 1. Boot
- `.env.local` needs `AUTH_MODE=local_noauth` and `DATAFORSEO_API_KEY` (base64 of `login:password`). Never print the key.
- Start `pnpm dev:agents` in the background. The server URL is branch-prefixed: `http://<branch-suffix>.open-seo.localhost:1355` (the exact URL is printed on boot; logs tee to `.logs/dev-server.log`).
- With `local_noauth`, `/mcp` needs no token. Vite hot-reloads server code, so fix → re-call without restarting.
## 2. Protocol smoke (cheap, deterministic)
Raw JSON-RPC against `/mcp` — the layer for asserting exact shapes and driving edge cases (resume taskIds, empty results, invalid inputs):
```bash
curl -sS http://<url>/mcp \
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# tools/call: {"method":"tools/call","params":{"name":"<tool>","arguments":{...}}}
```
- Bootstrap: `list_projects`, then `create_project` if empty — most tools need a `projectId`.
- Test the happy path AND at least one edge per changed tool: an empty result (obscure query), an invalid identifier, and for queued tools the full lifecycle including resuming with the returned `taskId`.
- These are real, billed DataForSEO calls (metering itself short-circuits in `local_noauth` — billing needs unit tests, not this). Keep depths 1020.
## 3. Consumer probe (the ergonomics test)
Spawn a headless Claude subprocess connected as a real MCP client. Write a config:
```json
{
"mcpServers": {
"openseo-local": { "type": "http", "url": "http://<url>/mcp" }
}
}
```
Then run a NATURAL task — never name the tools; whether the model finds them from descriptions alone is the test:
```bash
claude -p "<natural task a customer would ask>. Keep spend minimal: depths 10-20, one 3x3 grid max, ~10 paid calls.
Deliver two sections: 1. FINDINGS — the task result. 2. MCP FEEDBACK — critique the MCP as a first-time consumer:
were descriptions enough to pick tools without trial and error? confusing schemas, surprising output shapes or sizes,
unclear errors, credit-cost surprises? Did async/taskId flows behave as described? List anything that made you hesitate or retry." \
--mcp-config mcp-local.json --strict-mcp-config \
--allowedTools "mcp__openseo-local,mcp__openseo-local__*" \
--model sonnet --max-turns 30
```
Use `--model sonnet` as the typical-client proxy — if sonnet navigates it cold, weaker clients likely can too. Read FINDINGS for correctness (did it get real, sensible data?) and MCP FEEDBACK for the rubric below.
## 4. Ergonomics rubric — what feedback to act on
- **Tool selection**: the probe should pick the right tool first try. Retries or wrong-tool detours mean a description needs a sharper "use this when / not this" sentence.
- **Schemas**: every constraint the provider enforces silently must be in the field's `.describe()` (units, whole-number requirements, defaults, what's ignored when). If the probe guessed-and-retried an input, encode the rule server-side (coerce/round) or document it — prefer coercing.
- **Output size**: budget roughly a few KB per row. Provider rows carrying `popular_times`/attribute trees/photo URLs must be trimmed to the fields the tool's job needs; point to the single-entity tool for the full shape.
- **Errors**: actionable, never a raw upstream field name without a hint at the fix. Failures after a billed step must keep the recovery handle (e.g. the taskId) in the message.
- **Async copy**: descriptions must match typical latency ("usually completes within this call") and the resume path must actually work when driven by the probe, not just by curl.
- **Credit honesty**: each description's credit sentence matches reality, including cache-hit and resume paths.
## 5. Iterate and clean up
Fix findings → hot-reload picks them up → re-verify just the changed behavior via curl (cheap) → rerun the full consumer probe once per iteration round (it re-tests selection and flow, not just the fix). When done: stop the dev server background task, run the repo's tests/`ci:check`, and fold genuine provider quirks into code comments or tests so the next agent doesn't rediscover them.

View File

@ -0,0 +1 @@
../../.agents/skills/create-repo-skill

1
.claude/skills/deslop Symbolic link
View File

@ -0,0 +1 @@
../../.agents/skills/deslop

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Stephen D. Turner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,81 +0,0 @@
# deslop
A Claude skill for removing AI writing patterns from prose.
## What it does
When you write, draft, edit, or review text, `deslop` identifies and eliminates predictable AI tells: formulaic sentence structures, filler phrases, false agency, dramatic fragmentation, vague declaratives, and dozens of other patterns that signal machine-generated writing.
The skill works across any prose context. Examples are weighted toward scientific writing and technical blog posts, but the rules apply to any writing where you want a human voice without the AI veneer. For scientific contexts specifically, the skill accounts for conventions like passive voice in methods sections and domain-specific terminology.
## Installation
**Option 1: Download ZIP**
Click the green **Code** button at the top of this repo, then **Download ZIP**. Extract the ZIP and add the folder to your Claude skills directory.
**Option 2: Releases**
Go to the [Releases](https://github.com/stephenturner/skill-deslop/releases) page and download the latest `.skill` file. Add it to your Claude skills in [customize/skills](https://claude.ai/customize/skills) on the web, or double-click it if you have Claude Desktop installed.
**Option 3: Build it yourself**
Build a `.skill` file from the source code, then add it to your Claude skills as described above.
```sh
git clone https://github.com/stephenturner/skill-deslop.git
cd skill-deslop
zip -r deslop.skill SKILL.md references/
```
## How to use it
Install as described above, then use it like you normally talk to Claude. The skill triggers automatically when you:
- Ask Claude to write prose (blog posts, essays, articles, memos, newsletters) making it sound natural instead of AI-generated
- Ask Claude to "deslop", "de-AI" or "make it sound human"
- Ask Claude to check for "slop" or AI patterns
You can also reference the skill directly:
- "Review this draft using the deslop checklist"
- "Score this text on the deslop rubric"
- "Rewrite this paragraph to pass the deslop quick checks"
## Scoring rubric
The skill includes a 1-10 scoring rubric across five dimensions:
| Dimension | Question |
| ------------ | -------------------------------------- |
| Directness | Statements or announcements? |
| Rhythm | Varied or metronomic? |
| Trust | Respects reader intelligence? |
| Authenticity | Sounds like a specific human wrote it? |
| Density | Anything cuttable? |
Below 35/50: revise.
## Skill structure
```
deslop/
├── SKILL.md # Core rules, quick checks, scoring rubric
├── README.md # This file
└── references/
├── phrases.md # Phrases to remove or replace
├── structures.md # Structural patterns to avoid
├── tropes.md # Full catalog of AI writing tropes
└── examples.md # Before/after transformations
```
## Acknowledgments
This skill was built in part by combining and synthesizing material from two open sources:
- **AI writing tropes catalog** from [tropes.fyi](https://tropes.fyi/) by [Ossama Hassanein](https://ossama.is). The `references/tropes.md` file is adapted from this source, and trope patterns are integrated throughout the other reference files.
- **stop-slop** from [github.com/hardikpandya/stop-slop](https://github.com/hardikpandya/stop-slop) by [Hardik Pandya](https://hvpandya.com). The phrase lists, structural patterns, before/after examples, scoring rubric, and quick checks draw from this project.
## License
MIT

View File

@ -1,136 +0,0 @@
---
name: deslop
description: Remove AI writing patterns from prose. Use this skill when writing, drafting, editing, reviewing, or revising any text to eliminate predictable AI tells, slop, and formulaic patterns. Trigger this skill whenever the user asks to "deslop", "de-AI", "make it sound human," "remove AI patterns," "remove AI tropes," "clean up AI writing," fix "slop," "deslop" text, or review prose for authenticity. Also use when the user asks you to write or draft anything and wants it to sound natural rather than AI-generated. Common use cases include scientific writing (manuscripts, abstracts, cover letters, grant narratives, discussion sections, peer review responses), blog posts, newsletters, memos, reports, and any other substantial prose.
metadata:
internal: true
---
# Deslop: Remove AI Writing Patterns from Prose
Strip predictable AI patterns from writing. Make prose sound like a specific human wrote it, not like a language model generated it.
## When to Apply
- Any request to "make it sound human" or "deslop" writing
- Any prose (articles, blog posts, essays, memos, newsletters, reports) or scientific writing (manuscripts, abstracts, cover letters, grant narratives, discussion sections, peer review responses) where the user wants it to sound natural rather than AI-generated
- Editing or revising existing text where the user wants it to sound natural rather than AI-generated
- Reviewing text for AI tells
## Core Rules
### 1. Cut filler phrases
Remove throat-clearing openers ("Here's the thing:"), emphasis crutches ("Let that sink in."), business jargon ("navigate the landscape"), and meta-commentary ("In this section, we'll explore..."). See [references/phrases.md](references/phrases.md) for the full catalog.
### 2. Break formulaic structures
Avoid binary contrasts ("Not X. Y."), negative listings ("Not a X. Not a Y. A Z."), dramatic fragmentation ("Speed. That's it. That's the tradeoff."), self-posed rhetorical questions ("The result? Devastating."), and anaphora/tricolon abuse. See [references/structures.md](references/structures.md) for patterns and fixes.
### 3. Eliminate AI tropes
Watch for the full catalog of AI writing tells: "quietly" and other magic adverbs, "delve" and its cousins, the "serves as" dodge, false ranges ("from X to Y" where the range is meaningless), superficial participle analyses ("highlighting its importance"), invented concept labels ("the supervision paradox"), grandiose stakes inflation, patronizing analogies, and false vulnerability. See [references/tropes.md](references/tropes.md) for the complete list with examples.
### 4. Use active voice with human subjects
Prefer active constructions with named actors. "The complaint becomes a fix" is wrong. "The team fixed it" is right. If no specific person fits, use "we" in scientific prose or "you" in blog posts.
### 5. Be specific
No vague declaratives ("The reasons are structural"). Name the specific thing. No lazy extremes ("every," "always," "never") doing vague work. No vague attributions ("Experts argue..."). If you cannot name the expert, you do not have a source.
In scientific writing, domain terminology is fine and expected. "Weighted interval score" is precise language, not jargon. The problem is business buzzwords ("leverage," "landscape," "ecosystem") and AI vocabulary tells ("delve," "tapestry," "nuanced") leaking into technical prose.
### 6. Match register to context
In blog posts and newsletters, put the reader in the room. "You" beats "People." Specifics beat abstractions. No narrator-from-a-distance voice.
In scientific writing, maintain appropriate formality. Use "we" for your own work, cite specific authors instead of "researchers have shown," and avoid both the distant narrator ("It has long been recognized that...") and the overly casual blog voice. State claims and back them with citations.
### 7. Vary rhythm
Mix sentence lengths. Two items beat three. End paragraphs differently. No em dashes. Do not stack short punchy fragments for manufactured emphasis. Do not write listicles disguised as prose ("The first wall... The second wall...").
### 8. Trust readers
State facts directly. Skip softening, justification, hand-holding. No "Let's break this down." No "Think of it as..." No pedagogical voice unless the audience genuinely needs it. No fractal summaries (telling the reader what you are about to say, saying it, then summarizing what you said).
### 9. Watch formatting tells
No bold-first bullets (every list item starting with a bolded keyword). No unicode arrows. No em dashes. No signposted conclusions ("In conclusion..."). No "Despite these challenges..." formulas. These are strong AI signals.
### 10. Do not dilute
One point per section. Do not restate the same argument in ten different ways across thousands of words. Do not beat a single metaphor to death. Do not stack historical analogies for false authority ("Apple didn't build Uber. Facebook didn't build Spotify...").
## Quick Checks
Run these before delivering any prose:
- Heavy use of adverbs or -ly words? Cut them.
- Any passive voice? Find the actor, make them the subject.
- Inanimate thing doing a human verb? Name the person.
- Any "here's what/this/that" throat-clearing? Cut to the point.
- Any "not X, it's Y" contrasts? State Y directly.
- Any self-posed rhetorical question answered immediately? Fold into a statement.
- Three consecutive sentences match length? Break one.
- Paragraph ends with a punchy one-liner? Vary it.
- Em dash anywhere? Remove it. Use a comma or period or a parenthetical.
- Vague declarative ("The implications are significant")? Name the specific implication.
- Any sentence starting with What/When/Where/Which/Who/Why/How as a crutch? Restructure.
- Meta-joiners ("The rest of this essay...")? Delete.
- "It's worth noting" or similar filler transitions? Delete.
- Same metaphor used more than twice? Replace or cut repeats.
- "Despite these challenges..." formula? Rewrite.
- Bold-first bullet pattern? Remove bold leads.
- Tricolon (three-item list)? Use two items or one.
## Scoring
When reviewing text, rate 1-10 on each dimension:
| Dimension | Question |
| ------------ | -------------------------------------- |
| Directness | Statements or announcements? |
| Rhythm | Varied or metronomic? |
| Trust | Respects reader intelligence? |
| Authenticity | Sounds like a specific human wrote it? |
| Density | Anything cuttable? |
Below 35/50: revise.
## Reference Files
Consult these for detailed catalogs when writing or editing:
- [references/phrases.md](references/phrases.md): Phrases to remove or replace (throat-clearing, emphasis crutches, business jargon, adverbs, meta-commentary, vague declaratives)
- [references/structures.md](references/structures.md): Structural patterns to avoid (binary contrasts, negative listings, dramatic fragmentation, rhetorical setups, false agency, passive voice, rhythm problems)
- [references/tropes.md](references/tropes.md): Full catalog of AI writing tropes (word choice, sentence structure, paragraph structure, tone, formatting, composition)
- [references/examples.md](references/examples.md): Before/after transformations showing how to fix common patterns
## Examples
See [references/examples.md](references/examples.md) for before/after transformations.
**Quick inline example (scientific writing):**
Before:
> "It's worth noting that these findings have important implications for how we navigate the challenges of forecast ensembling moving forward. Despite these challenges, this work contributes meaningfully to the growing body of literature, highlighting the need for continued evaluation."
After:
> "If individual model rankings are unstable across geography and time, ensemble methods that weight models by past performance may not improve on equal-weight approaches."
Changes: Replaced filler transition, vague declarative, "despite these challenges" formula, and superficial participle analysis with the specific implication.
**Quick inline example (blog post):**
Before:
> "Here's the thing: most bioinformatics pipelines break in production. Not because the code is bad. Because the data is bad. Let that sink in."
After:
> "Most bioinformatics pipelines break in production. The code runs fine. The data doesn't match the assumptions baked into it."
Changes: Removed opener, binary contrast, and emphasis crutch. Named the specific problem.

View File

@ -1,209 +0,0 @@
# Before/After Examples
## Example 1: Throat-Clearing + Binary Contrast (Scientific)
**Before:**
> "Here's the thing: forecasting infectious disease is hard. Not because the models are complex. Because the data is complex. Let that sink in."
**After:**
> "Forecasting infectious disease is hard. The models are tractable. The data, collected under shifting surveillance definitions and reporting lags, is not."
**Changes:** Removed opener, binary contrast structure, and emphasis crutch. Named the specific problem with the data.
---
## Example 2: Filler + "Despite These Challenges" (Cover Letter)
**Before:**
> "It's worth noting that these findings have important implications for how we navigate the challenges of forecast ensembling moving forward. Despite these challenges, this work contributes meaningfully to the growing body of literature, highlighting the need for continued evaluation and underscoring the importance of robust benchmarking."
**After:**
> "If individual model rankings are unstable across geography and time, ensemble methods that weight models by past performance may not improve on equal-weight approaches."
**Changes:** Replaced filler transition, vague declarative, "despite these challenges" formula, and two superficial participle phrases with the specific implication of the findings.
---
## Example 3: Grandiose Stakes + Landscape (Scientific)
**Before:**
> "In today's rapidly evolving genomic landscape, single-cell RNA sequencing has fundamentally reshaped how we think about cellular heterogeneity. This paradigm shift has far-reaching implications for our understanding of disease."
**After:**
> "Single-cell RNA sequencing reveals cell-type-specific expression patterns that bulk methods average out. In tumor samples, this distinction matters: rare resistant subpopulations visible in single-cell data disappear in bulk profiles."
**Changes:** Eliminated "landscape," "paradigm shift," "fundamentally," and the vague stakes claim. Replaced with a concrete example of why the method matters.
---
## Example 4: Passive Voice + False Agency (Discussion Section)
**Before:**
> "It was observed that model performance degraded at longer forecast horizons. The uncertainty naturally increased as the prediction window expanded. These results emerged from our analysis of 54 state-level forecasts."
**After:**
> "We observed that model performance degraded at longer forecast horizons. Each additional week of lead time added roughly 15% to the mean WIS. We saw this pattern across all 54 state-level forecasts."
**Changes:** Named the actor ("we"). Replaced false agency ("uncertainty naturally increased," "results emerged") with specific claims and a number.
---
## Example 5: Self-Posed Rhetorical Question (Blog Post)
**Before:**
> "What if I told you that most bioinformatics pipelines break in production? The result? Wasted compute and silent errors. The worst part? Nobody checks the intermediate outputs. Here's why that matters:"
**After:**
> "Most bioinformatics pipelines break in production, and the failures are quiet. The FASTQ passes QC. The alignment runs. But the reference genome version changed between runs, and the variant calls shift without warning."
**Changes:** Removed rhetorical setup and three self-posed questions. Replaced with a specific failure scenario the reader can picture.
---
## Example 6: "Serves As" + Superficial Participle Analysis (Abstract)
**Before:**
> "The FluSight initiative serves as a foundational framework for influenza forecasting in the United States, contributing to public health preparedness and underscoring the importance of collaborative forecasting efforts."
**After:**
> "The FluSight initiative coordinates influenza forecasting across dozens of modeling groups in the United States. Since 2013, it has standardized targets, submission formats, and evaluation metrics."
**Changes:** Replaced "serves as a foundational framework" with what FluSight does. Replaced two participle phrases with a specific fact.
---
## Example 7: Narrator Distance + Vague Declarative (Blog Post)
**Before:**
> "Nobody designed the current state of genomic data sharing. The reasons are structural. This is genuinely hard, and the implications are significant."
**After:**
> "You inherit a genomic data sharing system that grew by accretion: dbGaP for controlled access, GEO for expression, SRA for raw reads, ClinVar for variants. Each solves one problem. None talks to the others without custom glue code."
**Changes:** Replaced narrator distance ("Nobody designed...") with "you." Replaced two vague declaratives with the specific structural problem.
---
## Example 8: Listicle in a Trench Coat (Discussion Section)
**Before:**
> "The first limitation of our study is the relatively short evaluation window. The second limitation is the lack of a standardized baseline across all forecasters. The third limitation is the potential for overfitting to the specific characteristics of the 2021-22 and 2022-23 seasons."
**After:**
> "Our evaluation covers only two seasons, and both followed the unusual post-COVID influenza dynamics that may not generalize. We also lacked a standardized baseline: each forecaster chose their own training data and feature set, making apples-to-apples comparison difficult."
**Changes:** Replaced the numbered listicle structure with woven prose. Combined related points and added specificity about why the limitations matter.
---
## Example 9: One-Point Dilution (Blog Post)
**Before:**
> "Reproducibility matters. In fact, reproducibility is the most underrated quality in computational biology. When you make your analysis reproducible, you reduce errors. Fewer errors mean more trust. More trust means faster adoption. The lesson? Make it reproducible. Irreproducible analyses are the enemy of science. The best papers are the reproducible ones."
**After:**
> "Reproducible analyses catch errors earlier. When Beaulieu-Jones and Greene re-ran 68 papers from the PharmacoGenomics Knowledge Base, only 40% reproduced. The ones that failed had no shared code or pinned dependency versions."
**Changes:** Eight sentences restating one point became three sentences with a specific supporting example and a number.
---
## Example 10: Historical Analogy Stacking (General)
**Before:**
> "Apple didn't build Uber. Facebook didn't build Spotify. Stripe didn't build Shopify. AWS didn't build Airbnb. Every major technological shift follows the same pattern."
**After:**
> "AWS sold infrastructure. Airbnb built a business on top of it. That split between platform and product repeats across the industry."
**Changes:** One concrete example examined in depth instead of four name-drops. Named the specific pattern.
---
## Example 11: Anaphora Abuse (Grant Narrative)
**Before:**
> "We will develop novel computational methods. We will apply these methods to large-scale genomic datasets. We will validate our findings using independent cohorts. We will disseminate our tools through open-source repositories. We will train the next generation of computational biologists."
**After:**
> "We will develop and validate statistical methods for multi-ancestry fine-mapping using UK Biobank and TOPMed cohorts, then release them as an R package with documentation and tutorials suitable for graduate training."
**Changes:** Collapsed five anaphoric sentences into one that names specific methods, datasets, and deliverables.
---
## Example 12: Dramatic Fragmentation (General)
**Before:**
> "Speed. Quality. Cost. You can only pick two. That's it. That's the tradeoff."
**After:**
> "Speed, quality, cost: pick two."
**Changes:** Single sentence. No performative emphasis.
---
## Example 13: False Vulnerability + Meta-Commentary (Blog Post)
**Before:**
> "And yes, since we're being honest: I've run plenty of analyses where the p-value was borderline and I squinted at it until it cooperated. I want to explore why that impulse is so common. In this post, I'll walk you through what I've learned."
**After:**
> "I've nudged a borderline p-value along by trying one more covariate. You probably have too. The question is what makes that feel acceptable in the moment, and the answer is usually that the rest of the analysis already 'looks right.'"
**Changes:** Replaced false vulnerability with a specific, honest admission. Cut the meta-commentary ("In this post, I'll walk you through"). Stated the point instead of announcing it.
---
## Example 14: "It's Worth Noting" + Invented Concept Label (Scientific)
**Before:**
> "It's worth noting that this creates what might be called the 'calibration paradox': models that are well-calibrated at the national level may be poorly calibrated at the state level, reflecting broader trends in the tension between aggregation and granularity."
**After:**
> "National-level calibration does not guarantee state-level calibration. A model can produce well-calibrated 90% intervals for the US overall while consistently undercovering in states with smaller populations and noisier surveillance data."
**Changes:** Cut the filler transition and the invented concept label. Replaced the superficial participle analysis with the specific mechanism (small states, noisy data).
---
## Example 15: "Imagine a World" + Patronizing Analogy (General)
**Before:**
> "Imagine a world where every meeting had a clear agenda. Think of it like a recipe: you wouldn't start cooking without knowing the ingredients. That's the promise of async-first communication. Let's unpack why this matters."
**After:**
> "Meetings without agendas waste time. A 15-person sync with no written agenda averages 47 minutes and produces no decisions (Atlassian, 2019). Writing the agenda forces the organizer to decide whether the meeting is necessary at all."
**Changes:** Removed the "imagine" opener, the cooking analogy (which adds nothing), and the pedagogical "let's unpack." Replaced with a specific claim, a number, and the mechanism that makes agendas work.

View File

@ -1,215 +0,0 @@
# Phrases to Remove or Replace
## Throat-Clearing Openers
Remove these. State the content directly.
- "Here's the thing:"
- "Here's what [X]"
- "Here's this [X]"
- "Here's that [X]"
- "Here's why [X]"
- "Here's the kicker"
- "Here's where it gets interesting"
- "Here's what most people miss"
- "Here's the deal"
- "The uncomfortable truth is"
- "It turns out"
- "The real [X] is"
- "Let me be clear"
- "The truth is,"
- "I'll say it again:"
- "I'm going to be honest"
- "Can we talk about"
- "Here's what I find interesting"
- "Here's the problem though"
Any "here's what/this/that" construction is throat-clearing before the point. Cut it and state the point.
## Emphasis Crutches
These add no meaning. Delete them.
- "Full stop." / "Period."
- "Let that sink in."
- "This matters because"
- "Make no mistake"
- "Here's why that matters"
## Pedagogical Hand-Holding
Phrases that assume the reader needs a teacher. Cut them.
- "Let's break this down"
- "Let's unpack this"
- "Let's explore"
- "Let's dive in"
- "Let's delve into"
- "Think of it as..."
- "Think of it like..."
- "Imagine a world where..."
## Business Jargon
Replace with plain language.
| Avoid | Use instead |
| --------------------- | ---------------------------- |
| Navigate (challenges) | Handle, address |
| Unpack (analysis) | Explain, examine |
| Lean into | Accept, embrace |
| Landscape (context) | Situation, field |
| Game-changer | Significant, important |
| Double down | Commit, increase |
| Deep dive | Analysis, examination |
| Take a step back | Reconsider |
| Moving forward | Next, from now |
| Circle back | Return to, revisit |
| On the same page | Aligned, agreed |
| Leverage (verb) | Use |
| Utilize | Use |
| Robust | Strong, solid |
| Streamline | Simplify |
| Harness | Use, apply |
| Paradigm | Model, approach |
| Synergy | Cooperation, combined effect |
| Ecosystem | System, field, community |
| Framework | Structure, approach |
## AI Vocabulary Tells
Words that became dramatically overrepresented in AI-generated text. Avoid or replace.
- "delve" (use: examine, look at, explore)
- "tapestry" (use: mix, combination, range)
- "certainly" (usually deletable)
- "landscape" when meaning "field" or "situation"
- "nuanced" (use: complex, subtle, specific)
## The "Serves As" Dodge
AI replaces simple "is" or "are" with pompous alternatives. Use the simple verb.
| Avoid | Use instead |
| ------------------------------ | ----------- |
| serves as | is |
| stands as | is |
| marks (when meaning "is") | is |
| represents (when meaning "is") | is |
## Adverbs
Kill all adverbs. No -ly words. No softeners, no intensifiers, no hedges.
Specific offenders:
- "really"
- "just"
- "literally"
- "genuinely"
- "honestly"
- "simply"
- "actually"
- "deeply"
- "truly"
- "fundamentally"
- "inherently"
- "inevitably"
- "interestingly"
- "importantly"
- "crucially"
- "quietly" (AI's favorite for conveying subtle importance)
- "remarkably"
- "arguably"
Also cut these filler phrases:
- "At its core"
- "In today's [X]"
- "It's worth noting"
- "It bears mentioning"
- "Notably"
- "At the end of the day"
- "When it comes to"
- "In a world where"
- "The reality is"
## Meta-Commentary
Remove self-referential asides. The text should move, not announce its own structure.
- "Hint:"
- "Plot twist:" / "Spoiler:"
- "You already know this, but"
- "But that's another post"
- "X is a feature, not a bug"
- "Dressed up as"
- "The rest of this essay explains..."
- "Let me walk you through..."
- "In this section, we'll..."
- "As we'll see..."
- "I want to explore..."
- "In conclusion" / "To sum up" / "In summary"
- "As we've seen in this section..."
- "And so we return to where we began."
## Performative Emphasis
False intimacy or manufactured sincerity:
- "creeps in"
- "I promise"
- "They exist, I promise"
## False Vulnerability
Simulated self-awareness that reads as performative:
- "And yes, I'm openly..."
- "And yes, since we're being honest..."
- "This is not a rant; it's a diagnosis"
## Telling Instead of Showing
Announcing difficulty or significance rather than demonstrating it:
- "This is genuinely hard"
- "This is what leadership actually looks like"
- "This is what X actually looks like"
- "actually matters"
## "The Truth Is Simple"
Asserting clarity instead of demonstrating it:
- "The reality is simpler"
- "History is unambiguous on this point"
- "History is clear, the metrics are clear, the examples are clear"
- "but none of them is the real story. The real story is..."
## Vague Declaratives
Sentences that announce importance without naming the specific thing. Kill these or replace with the specific thing.
- "The reasons are structural"
- "The implications are significant"
- "This is the deepest problem"
- "The stakes are high"
- "The consequences are real"
## Vague Attributions
Attributing claims to unnamed authorities. If you cannot name the source, you do not have one.
- "Experts argue that..."
- "Industry reports suggest that..."
- "Observers have cited..."
- "Several publications have noted..."
## Grandiose Stakes Inflation
Inflating every argument to world-historical significance. Scale claims to match the actual stakes.
- "This will fundamentally reshape how we think about everything."
- "will define the next era of computing"
- "something entirely new"

View File

@ -1,257 +0,0 @@
# Structures to Avoid
## Binary Contrasts (Negative Parallelism)
The single most commonly identified AI writing tell. Creates false drama by framing everything as a surprising reframe. One in a piece can work; multiple instances per piece is a strong AI signal. Before LLMs, people did not write like this at scale.
| Pattern | Problem |
| ------------------------------------------------------------- | ------------------------------ |
| "Not because X. Because Y." / "Not because X, but because Y." | Telegraphed reversal |
| "[X] isn't the problem. [Y] is." | Formulaic reframe |
| "The answer isn't X. It's Y." | Predictable pivot |
| "It feels like X. It's actually Y." | Setup/reveal cliche |
| "The question isn't X. It's Y." | Rhetorical misdirection |
| "Not X. But Y." / "not X, it's Y" / "isn't X, it's Y" | Mechanical contrast |
| "It's not this. It's that." | Same formula, different words |
| "stops being X and starts being Y" | False transformation arc |
| "doesn't mean X, but actually Y" | Negation-then-assertion crutch |
| "is about X but not Y" | False distinction |
| "not just X but also Y" | Additive hedge |
**Fix:** State Y directly. "The problem is Y." Drop the negation entirely.
## Negative Listing
Listing what something is _not_ before revealing what it _is_. A dramatic countdown through negation.
| Pattern | Problem |
| -------------------------------------------- | --------------------------------- |
| "Not a X... Not a Y... A Z." | Dramatic buildup through negation |
| "It wasn't X. It wasn't Y. It was Z." | Same structure, past tense |
| "Not ten. Not fifty. Five hundred." | Numerical countdown reveal |
| "not recklessly, not completely, but enough" | Hedging disguised as precision |
**Fix:** State Z. The reader does not need the runway.
## Dramatic Fragmentation
Sentence fragments for emphasis read as manufactured profundity. RLHF training has pushed models toward "writing for readability" aimed at the lowest common denominator: one thought per sentence, no mental state-keeping required. No human writes first drafts this way.
| Pattern | Problem |
| ---------------------------------------- | ------------------------------ |
| "[Noun]. That's it. That's the [thing]." | Performative simplicity |
| "X. And Y. And Z." | Staccato drama |
| "This unlocks something. [Word]." | Artificial revelation |
| "He published this. Openly. In a book." | Fragment stacking for emphasis |
| "Platforms do." | Orphaned fragment as punchline |
**Fix:** Complete sentences. Trust content over presentation.
## Self-Posed Rhetorical Questions
The model asks a question nobody was asking, then answers it for dramatic effect.
| Pattern | Problem |
| --------------------------------------- | ---------------------- |
| "The result? Devastating." | Manufactured suspense |
| "The worst part? Nobody saw it coming." | Same formula |
| "What if [reframe]?" | Socratic posturing |
| "Here's what I mean:" | Redundant preview |
| "Think about it:" | Condescending prompt |
| "And that's okay." | Unnecessary permission |
**Fix:** Make the point. Let readers draw conclusions.
## Anaphora Abuse
Repeating the same sentence opening multiple times in quick succession.
| Pattern | Problem |
| ---------------------------------------------------------------- | --------------------------- |
| "They assume that... They assume that... They assume that..." | Mechanical repetition |
| "They could expose... They could offer... They could provide..." | List disguised as prose |
| "They have built X, but not Y. They have built A, but not B." | Parallel structure stacking |
**Fix:** Vary sentence openings. Combine related points into single sentences.
## Tricolon Abuse
Overuse of the rule-of-three pattern. A single tricolon is fine; multiple back-to-back tricolons are an AI pattern.
| Pattern | Problem |
| ------------------------------------------------------------------------ | --------------------------------------- |
| "Products impress; platforms empower. Products solve; platforms create." | Parallel tricolon stacking |
| "identity, payments, compute, distribution" | Extended lists masquerading as analysis |
| "workflows, decisions, and interactions" | Three-item groupings everywhere |
**Fix:** Use two items or one. Break the three-item habit.
## False Agency
Giving inanimate things human verbs. AI loves this because it avoids naming the actor.
| Pattern | Problem |
| ------------------------------- | ---------------------------------------- |
| "a complaint becomes a fix" | Someone fixed it. |
| "a bet lives or dies in days" | Someone kills or ships the project. |
| "the decision emerges" | Someone decides. |
| "the culture shifts" | People change behavior. |
| "the conversation moves toward" | Someone steers. |
| "the data tells us" | Someone reads it and draws a conclusion. |
| "the market rewards" | Buyers pay for things. |
**Fix:** Name the human. "The team fixed it that week" beats "the complaint becomes a fix." If no specific person fits, use "you" to put the reader in the seat.
## Narrator-from-a-Distance
Floating above the scene instead of putting the reader in it.
| Pattern | Problem |
| ------------------------- | ----------------------- |
| "Nobody designed this." | Disembodied observation |
| "This happens because..." | Lecturer voice |
| "This is why..." | Same |
| "People tend to..." | Armchair sociologist |
**Fix:** Put the reader in the room. "You don't sit down one day and decide to..." beats "Nobody designed this."
## Passive Voice
Every sentence needs a subject doing something. Passive voice hides the actor and drains energy.
| Pattern | Fix |
| -------------------------- | -------------------- |
| "X was created" | Name who created it |
| "It is believed that" | Name who believes it |
| "Mistakes were made" | Name who made them |
| "The decision was reached" | Name who decided |
**Fix:** Find the actor. Put them at the front of the sentence.
## Listicle in a Trench Coat
Numbered or labeled points dressed up as continuous prose. The model writes a listicle but wraps each point in a paragraph starting with "The first... The second... The third..." to disguise the format.
| Pattern | Problem |
| ----------------------------------------------------------------- | ------------------------------------ |
| "The first wall is... The second wall is... The third wall is..." | Numbered list pretending to be prose |
| "The second takeaway is... The third takeaway is..." | Same |
**Fix:** If the content is a list, present it as a list. If it should be prose, weave the points together without numbering.
## Superficial Participle Analyses
Tacking a present participle phrase onto the end of a sentence to inject shallow analysis.
| Pattern | Problem |
| ----------------------------------------------------- | ----------------------------- |
| "contributing to the region's rich cultural heritage" | Hollow significance-signaling |
| "highlighting its enduring legacy" | Same |
| "underscoring its role as a dynamic hub" | Same |
| "reflecting broader trends in..." | Same |
**Fix:** Either make a specific analytical claim or delete the participle phrase.
## False Ranges
"From X to Y" constructions where X and Y are not on any real scale. In legitimate use, "from X to Y" implies a spectrum with a meaningful middle. AI uses it to list two loosely related things.
| Pattern | Problem |
| ---------------------------------------------------------------------- | --------------------------------------- |
| "From innovation to implementation to cultural transformation." | No real spectrum |
| "From the singularity of the Big Bang to the grand cosmic web." | Grandiose range with nothing in between |
| "From problem-solving to scientific discovery to artistic expression." | Fancy list, not a range |
**Fix:** If the items are a list, list them. If there is a real spectrum, describe it.
## Historical Analogy Stacking
Rapid-fire listing of historical companies or tech revolutions to build false authority. Common in technical writing.
| Pattern | Problem |
| ------------------------------------------------------------------------------ | ----------------------------- |
| "Apple didn't build Uber. Facebook didn't build Spotify." | Shotgun historical references |
| "Every major shift -- the web, mobile, social, cloud -- followed..." | Revolution-listing |
| "Take Spotify... Or consider Uber... Airbnb followed... Shopify is another..." | Sequential name-dropping |
**Fix:** Use one example, examine it in depth. One well-analyzed case beats five name-drops.
## "Despite Its Challenges..."
AI acknowledges problems only to immediately dismiss them. Always follows the same beat.
| Pattern | Problem |
| --------------------------------------------------------------- | ---------------------------- |
| "Despite these challenges, the initiative continues to thrive." | Formulaic optimism |
| "Despite its prosperity, [X] faces challenges typical of..." | Structured dismiss-and-pivot |
**Fix:** If challenges are worth mentioning, analyze them. If they are not, skip them.
## Sentence Starters to Avoid
| Pattern | Fix |
| --------------------------------------------------------------- | ----------------------------------------------- |
| Sentences starting with What, When, Where, Which, Who, Why, How | Restructure. Lead with the subject or the verb. |
| Paragraphs starting with "So" | Start with content |
| Sentences starting with "Look," | Remove |
Wh- openers become a crutch. "What makes this hard is..." becomes "The constraint is..." or better, name the specific constraint.
## Formulaic Constructions
| Pattern | Problem |
| ------------------------- | --------------------------- |
| "By the time X, I was Y." | Narrative template |
| "X that isn't Y" | Indirect. Say "X is broken" |
## Rhythm Patterns
| Pattern | Fix |
| ------------------------------ | ----------------------------------- |
| Three-item lists | Use two items or one |
| Questions answered immediately | Let questions breathe or cut them |
| Every paragraph ends punchily | Vary endings |
| Em dashes | Remove. Use commas or periods. |
| Staccato fragmentation | Do not stack short punchy sentences |
| "Not always. Not perfectly." | Hedging disguised as reassurance |
## Formatting Tells
| Pattern | Problem |
| ------------------------------------------- | -------------------------------------------------------------------------------- |
| Bold-first bullets | Every list item starting with a bolded keyword is an AI signal |
| Unicode arrows (→) | Use -> or => or plain text instead |
| Smart/curly quotes | Use straight quotes |
| Signposted conclusions ("In conclusion...") | Let the writing conclude naturally |
| Fractal summaries | Do not summarize what you are about to say, say it, then summarize what you said |
## One-Point Dilution
Making a single argument and restating it in ten different ways. The model pads a simple thesis to feel comprehensive by rephrasing the same idea with different metaphors, examples, and framings.
**Fix:** State the point once, support it, move on. If the piece circles back to the same claim more than twice, cut the repetitions.
## The Dead Metaphor
Latching onto a single metaphor and using it in every paragraph. A human writer introduces a metaphor, uses it, and moves on. AI repeats the same metaphor 5-10 times.
**Fix:** Use a metaphor once or twice. Then drop it.
## Invented Concept Labels
AI clusters invented compound labels that sound analytical without being grounded. It appends abstract problem-nouns (paradox, trap, creep, divide, vacuum, inversion) to domain words and uses them as if they are established terms.
| Pattern | Problem |
| ------------------------- | ------------------------------------ |
| "the supervision paradox" | Invented term treated as established |
| "the acceleration trap" | Same |
| "workload creep" | Same |
**Fix:** If the concept needs a name, define it. If it does not need a name, describe it in plain language.
## Word Patterns
| Pattern | Problem |
| ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Lazy extremes (every, always, never, everyone, everybody, nobody) | False authority. Use specifics instead of sweeping claims. |
| All adverbs (-ly words, "really," "just," "literally," "genuinely," "honestly," "simply," "actually") | Empty emphasis. See phrases.md for full list. |

View File

@ -1,360 +0,0 @@
# AI Writing Tropes to Avoid
Add this file to your AI assistant's system prompt or context to help it avoid
common AI writing patterns. Source: [tropes.fyi](https://tropes.fyi) by [ossama.is](https://ossama.is)
---
## Word Choice
### "Quietly" and Other Magic Adverbs
Overuse of "quietly" and similar adverbs to convey subtle importance or understated power. AI reaches for these adverbs to make mundane descriptions feel significant. Also includes: "deeply", "fundamentally", "remarkably", "arguably".
**Avoid patterns like:**
- "quietly orchestrating workflows, decisions, and interactions"
- "the one that quietly suffocates everything else"
- "a quiet intelligence behind it"
### "Delve" and Friends
Used to be the most infamous AI tell. "Delve" went from an uncommon English word to appearing in a staggering percentage of AI-generated text. Part of a family of overused AI vocabulary including "certainly", "utilize", "leverage" (as a verb), "robust", "streamline", and "harness".
**Avoid patterns like:**
- "Let's delve into the details..."
- "Delving deeper into this topic..."
- "We certainly need to leverage these robust frameworks..."
### "Tapestry" and "Landscape"
Overuse of ornate or grandiose nouns where simpler words would do. "Tapestry" is used to describe anything interconnected. "Landscape" is used to describe any field or domain. Other offenders: "paradigm", "synergy", "ecosystem", "framework".
**Avoid patterns like:**
- "The rich tapestry of human experience..."
- "Navigating the complex landscape of modern AI..."
- "The ever-evolving landscape of technology..."
### The "Serves As" Dodge
Replacing simple "is" or "are" with pompous alternatives like "serves as", "stands as", "marks", or "represents". AI avoids basic copulas because its repetition penalty pushes it toward fancier constructions (I've studied this!).
**Avoid patterns like:**
- "The building serves as a reminder of the city's heritage."
- "Gallery 825 serves as LAAA's exhibition space for contemporary art."
- "The station marks a pivotal moment in the evolution of regional transit."
---
## Sentence Structure
### Negative Parallelism
The "It's not X -- it's Y" pattern, often with an em dash. The single most commonly identified AI writing tell. Man I f\*cking hate it. AI uses this to create false profundity by framing everything as a surprising reframe. One in a piece can be effective; ten in a blog post is a genuine insult to the reader. Before LLMs, people simply did not write like this at scale. Includes the causal variant "not because X, but because Y" where every explanation is framed as a surprise reveal, the em-dash dismissal "X -- not Y", and the cross-sentence reframe where the same noun is negated then repositioned: "The question isn't X. The question is Y."
**Avoid patterns like:**
- "It's not bold. It's backwards."
- "Feeding isn't nutrition. It's dialysis."
- "Half the bugs you chase aren't in your code. They're in your head."
### "Not X. Not Y. Just Z."
The dramatic countdown pattern. AI builds tension by negating two or more things before revealing the actual point. Creates a false sense of narrowing down to the truth.
**Avoid patterns like:**
- "Not a bug. Not a feature. A fundamental design flaw."
- "Not ten. Not fifty. Five hundred and twenty-three lint violations across 67 files."
- "not recklessly, not completely, but enough"
### "The X? A Y."
Self-posed rhetorical questions answered immediately in the next sentence or clause. The model asks a question nobody was asking, then answers it for dramatic effect. Thinks this is the epitome of great writing.
**Avoid patterns like:**
- "The result? Devastating."
- "The worst part? Nobody saw it coming."
- "The scary part? This attack vector is perfect for developers."
### Anaphora Abuse
Repeating the same sentence opening multiple times in quick succession.
**Avoid patterns like:**
- "They assume that users will pay... They assume that developers will build... They assume that ecosystems will emerge... They assume that..."
- "They could expose... They could offer... They could provide... They could create... They could let... They could unlock..."
- "They have built engines, but not vehicles. They have built power, but not leverage. They have built walls, but not doors."
### Tricolon Abuse
Overuse of the rule-of-three pattern, often extended to four or five. A single tricolon is elegant; three back-to-back tricolons are a pattern recognition failure.
**Avoid patterns like:**
- "Products impress people; platforms empower them. Products solve problems; platforms create worlds. Products scale linearly; platforms scale exponentially."
- "identity, payments, compute, distribution"
- "workflows, decisions, and interactions"
### "It's Worth Noting"
Filler transitions that signal nothing. AI uses these phrases to introduce new points without actually connecting them to the previous argument. Also includes: "It bears mentioning", "Importantly", "Interestingly", "Notably".
**Avoid patterns like:**
- "It's worth noting that this approach has limitations."
- "Importantly, we must consider the broader implications."
- "Interestingly, this pattern repeats across industries."
### Superficial Analyses
Tacking a present participle ("-ing") phrase onto the end of a sentence to inject shallow analysis that says nothing. The model attaches significance, legacy, or broader meaning to mundane facts using phrases like "highlighting its importance", "reflecting broader trends", or "contributing to the development of...".
**Avoid patterns like:**
- "contributing to the region's rich cultural heritage"
- "This etymology highlights the enduring legacy of the community's resistance and the transformative power of unity in shaping its identity."
- "underscoring its role as a dynamic hub of activity and culture"
### False Ranges
Using "from X to Y" constructions where X and Y aren't on any real scale. In legitimate use, "from X to Y" implies a spectrum with a meaningful middle. AI uses it as a fancy way to list two loosely related things. "From innovation to cultural transformation" -- what's in between???? Nothing!
**Avoid patterns like:**
- "From innovation to implementation to cultural transformation."
- "From the singularity of the Big Bang to the grand cosmic web."
- "From problem-solving and tool-making to scientific discovery, artistic expression, and technological innovation."
---
## Paragraph Structure
### Short Punchy Fragments
Excessive use of very short sentences or sentence fragments as standalone paragraphs for manufactured emphasis. RLHF training has pushed models toward "writing for readability" aimed at the lowest common denominator: one thought per sentence, no mental state-keeping required. It's an inhuman style. No real person writes first drafts this way because it doesn't match how humans think or speak.
**Avoid patterns like:**
- "He published this. Openly. In a book. As a priest."
- "These weren't just products. And the software side matched. Then it professionalised. But I adapted."
- "Platforms do."
### Listicle in a Trench Coat
Numbered or labeled points dressed up as continuous prose. The model writes what is essentially a listicle but wraps each point in a paragraph that starts with "The first... The second... The third..." to disguise the format. Perhaps you told it to stop generating lists and it decided to do this instead... still very common.
**Avoid patterns like:**
- "The first wall is the absence of a free, scoped API... The second wall is the lack of delegated access... The third wall is the absence of scoped permissions..."
- "The second takeaway is that... The third takeaway is that... The fourth takeaway is that..."
---
## Tone
### "Here's the Kicker"
False suspense transitions that promise a revelation but deliver a point that did NOT need the buildup. The model uses these phrases to manufacture drama before an otherwise unremarkable observation LOL. Also includes: "Here's the thing", "Here's where it gets interesting", "Here's what most people miss", "Here's the starting point", "Here's the deal".
**Avoid patterns like:**
- "Here's the kicker."
- "Here's the thing about AI adoption."
- "Here's where it gets interesting."
### "Think of It As..."
The patronizing analogy. AI constantly reaches for "Think of it as..." or "It's like a..." to simplify concepts. The model defaults to teacher mode and assumes the reader needs a metaphor to understand anything. Often produces analogies that are less clear than the original concept.
**Avoid patterns like:**
- "Think of it like a highway system for data."
- "Think of it as a Swiss Army knife for your workflow."
- "It's like asking someone to buy a car they're only allowed to sit in while it's parked."
### "Imagine a World Where..."
The classic AI invitation to futurism. To sell the argument usually begins with "Imagine" followed by a list of wonderful things that will happen if the reader agrees with the premise.
**Avoid patterns like:**
- "Imagine a world where every tool you use -- your calendar, your inbox, your documents, your CRM, your code editor -- has a quiet intelligence behind it..."
- "In that world, workflows stop being collections of manual steps and start becoming orchestrations."
### False Vulnerability
Simulated self-awareness or honesty that reads as performative. The model pretends to break the fourth wall or admit a bias, creating a false sense of authenticity. Real vulnerability is specific and uncomfortable; AI vulnerability is polished and risk-free!!!!
**Avoid patterns like:**
- "And yes, I'm openly in love with the platform model"
- "And yes, since we're being honest: I'm looking at you, OpenAI, Google, Anthropic, Meta"
- "This is not a rant; it's a diagnosis"
### "The Truth Is Simple"
Asserting that something is obvious, clear or simple instead of actually proving it. If you have to tell the reader your point is clear, it very likely isn't. Also includes the dramatic reveal variant: "but none of them is the real story. The real story is..." -- claiming privileged insight while waving away everything before it.
**Avoid patterns like:**
- "The reality is simpler and less flattering"
- "History is unambiguous on this point"
- "History is clear, the metrics are clear, the examples are clear"
### Grandiose Stakes Inflation
Everything is the most important thing ever. AI inflates the stakes of every argument to world-historical significance. A blog post about API pricing becomes a meditation on the fate of civilization.
**Avoid patterns like:**
- "This will fundamentally reshape how we think about everything."
- "will define the next era of computing"
- "something entirely new"
### "Let's Break This Down"
The pedagogical voice that assumes the reader needs hand-holding. AI defaults to a teacher-student dynamic even when writing for expert audiences. Also includes: "Let's unpack this", "Let's explore", "Let's dive in".
**Avoid patterns like:**
- "Let's break this down step by step."
- "Let's unpack what this really means."
- "Let's explore this idea further."
### Vague Attributions
Attributing claims to unnamed authorities instead of being specific. AI loves to invoke "experts", "observers", "industry reports", and "several publications" without naming anyone. It also inflates the quantity of sources -- presenting what one person said as a widely held view, or writing "several publications have cited" when it means two. If you can't name the expert, you don't have a source.
**Avoid patterns like:**
- "Experts argue that this approach has significant drawbacks."
- "Industry reports suggest that adoption is accelerating."
- "Observers have cited the initiative as a turning point."
### Invented Concept Labels
AI clusters invented compound labels that sound analytical without being grounded. It appends abstract problem-nouns (paradox, trap, creep, divide, vacuum, inversion) to domain words — "supervision paradox", "acceleration trap", "workload creep" — and uses them as if they're established, rigorously defined terms. They function as rhetorical shorthand: name a thing, skip the argument. Multiple such labels in the same piece is a strong signal of AI slop.
**Avoid patterns like:**
- "the supervision paradox"
- "the acceleration trap"
- "workload creep"
---
## Formatting
### Em-Dash Addiction
Compulsive overuse of em dashes for dramatic pauses, parenthetical asides and pivot points. A human writer might use 2-3 per piece (and naturally); AI will use 20+.
**Avoid patterns like:**
- "The problem -- and this is the part nobody talks about -- is systemic."
- "The tinkerer spirit didn't die of natural causes -- it was bought out."
- "Not recklessly, not completely -- but enough -- enough to matter."
### Bold-First Bullets
Every bullet point or list item starts with a bolded phrase or sentence. Extremely common in Claude and ChatGPT markdown output. Almost nobody formats lists this way when writing by hand. It's a telltale sign of AI-generated documentation and blog posts AND README files (especially with emojis).
**Avoid patterns like:**
- "Every single bullet point begins with a bold keyword."
- "**Security**: Environment-based configuration with..."
- "**Performance**: Lazy loading of expensive resources..."
### Unicode Decoration
Use of unicode arrows (->), smart/curly quotes, and other special characters that can't be easily typed on a standard keyboard. Real writers typing in a text editor produce straight quotes and -> or =>. Claude in particular loves the -> arrow.
**Avoid patterns like:**
- "Input → Processing → Output"
- "This leads to better outcomes → which means higher engagement"
- "“Smart quotes” instead of straight "quotes" that youd actually type"
---
## Composition
### Fractal Summaries
"What I'm going to tell you; what I'm telling you; what I just told you" -- applied at every level of the document. Every subsection gets a summary. Every section gets a summary. The document itself gets a summary.
**Avoid patterns like:**
- "In this section, we'll explore... [3000 words later] ...as we've seen in this section."
- "A conclusion that restates every point already made in the previous 3000 words"
- "And so we return to where we began."
### The Dead Metaphor
Latching onto a single metaphor and beating it into the ground across the entire thing. A human writer would introduce a metaphor, use it then move on. AI will repeat the same metaphor 5-10 times.
**Avoid patterns like:**
- "The ecosystem needs ecosystems to build ecosystem value."
- "Walls and doors used 30+ times in the same article"
- "Every paragraph finds a way to say "primitives" again"
### Historical Analogy Stacking
ESPECIALLY COMMON IN TECHNICAL WRITING: Rapid-fire listing of historical companies or tech revolutions to build false authority.
**Avoid patterns like:**
- "Apple didn't build Uber. Facebook didn't build Spotify. Stripe didn't build Shopify. AWS didn't build Airbnb."
- "Every major technological shift -- the web, mobile, social, cloud -- followed the same pattern."
- "Take Spotify... Or consider Uber... Airbnb followed a similar path... Shopify is another example... Even Discord..."
### One-Point Dilution
Making a single argument and restating it in 10 different ways across thousands of words. The model pads a simple thesis to feel "comprehensive" by rephrasing the same idea with different metaphors, examples, and framings. An 800-word argument becomes 4000 words of circular repetition.
**Avoid patterns like:**
- "The same point, restated eight ways across 4000 words."
- "Each section rephrases the thesis with a different metaphor but adds nothing new"
### Content Duplication
Repeating entire sections or paragraphs verbatim within the same piece. This happens when the model loses track of what it has already written, especially in longer pieces. A dead giveaway of unedited AI output. Less common nowadays.
**Avoid patterns like:**
- "The same section appeared twice, word-for-word identical."
- "Paragraph 3 and paragraph 17 are the same sentence reworded"
### The Signposted Conclusion
Explicitly announcing the conclusion with "In conclusion", "To sum up", or "In summary". Competent writing doesn't need to tell you it's concluding. The reader can feel it. AI signals its structural moves because it's following a template, not writing organically.
**Avoid patterns like:**
- "In conclusion, the future of AI depends on..."
- "To sum up, we've explored three key themes..."
- "In summary, the evidence suggests..."
### "Despite Its Challenges..."
The rigid formula where AI acknowledges problems only to immediately dismiss them. Always follows the same beat: "Despite its [positive words], [subject] faces challenges..." then ends with "Despite these challenges, [optimistic conclusion].".
**Avoid patterns like:**
- "Despite these challenges, the initiative continues to thrive."
- "Despite its industrial and residential prosperity, Korattur faces challenges typical of urban areas."
- "Despite their promising applications, pyroelectric materials face several challenges that must be addressed for broader adoption."
---
Remember: any of these patterns used once might be fine. The problem is when
multiple tropes appear together or when a single trope is used repeatedly.
Write like a human: varied, imperfect, specific.

1
.claude/skills/merge-ready Symbolic link
View File

@ -0,0 +1 @@
../../.agents/skills/merge-ready

View File

@ -1,71 +0,0 @@
---
name: merge-ready
description: Take a branch from "code exists (or is about to)" to "ready for the maintainer's final review" — multi-axis subagent review with verified findings, fixes, ci:check, checkpoint commits, and an updated PR. Use whenever the user says a feature/fix/branch should be "merge ready", asks to get changes ready for review, or appends this to a build request ("build X and make it merge-ready").
metadata:
internal: true
---
# Merge ready
Drive the current work to the point where the only remaining step is the maintainer's own review and merge. The deliverable is a pushed branch with a clean `pnpm ci:check`, checkpoint commits along the way, and an open PR with a high-level description plus review instructions.
**Never merge the PR. The maintainer always reviews last.**
## 0. Figure out the starting point
This skill composes with feature work — it is not only a review pass:
- **Invoked alongside a build request** ("build X, make it merge-ready"): implement the feature/fix first, committing as you go, then continue below. The review phases cover _all_ changes on the branch vs `origin/main`, not just the last edit.
- **Invoked on existing work** ("make this branch merge-ready"): start directly at step 1. The scope is `git diff origin/main...HEAD` plus anything uncommitted.
## 1. Sync with main
- `git fetch origin main`. If the branch is behind, merge `origin/main` in and resolve conflicts (favor main's version for code this branch didn't intentionally change).
- **Checkpoint:** commit the merge before starting review, so conflict resolution is auditable separately from review fixes.
## 2. Multi-axis subagent review
Spawn independent review subagents **in parallel**, one per axis, each given repo access and the complete branch scope:
- committed changes: `git diff origin/main...HEAD`
- staged changes: `git diff --cached`
- unstaged changes: `git diff`
- untracked files: `git status --short`, followed by reading every in-scope untracked file
Do not let an uncommitted or newly created file escape review merely because it is absent from `origin/main...HEAD`.
1. **Unnecessary complexity** — thin wrappers, needless indirection, single-use abstractions, defensive guards for impossible states, dead config. This codebase deliberately stays simple.
2. **Security** — authz on new endpoints (org/project scoping), SSRF, injection, secrets handling, anything user-input-shaped reaching D1/R2/external APIs.
3. **Billing & metering** — ways a user could trigger DataForSEO/provider spend without being metered, charged-but-failed paths, retry/loop amplification, endpoints with unexpectedly high per-call user cost. Credits are billed via Autumn; uncounted spend is a revenue leak.
4. **Library & project idioms** — TanStack (Router/Query/Start) used idiomatically; patterns match how the rest of the codebase already does it (shared application/provider error boundaries, db/schema conventions, existing component patterns). Flag novel patterns where an established one exists.
5. **Vibe-coded cruft** — leftover scaffolding, stale comments narrating the edit history, console.logs, TODO-without-owner, copy-pasted near-duplicates, files/exports nothing uses.
Each reviewer returns findings with file:line, severity (`blocker` / `should-fix` / `nitpick`), and a one-line rationale. Tell reviewers explicitly: this is an early-stage product — do not chase theoretical edge cases; mark anything debatable as `nitpick`.
## 3. Verify findings — never blindly accept
For each `blocker` and `should-fix` finding, spawn verification subagents (in parallel) that adversarially check the finding against the actual code and verdict **APPLY / APPLY-MODIFIED / REJECT** with reasoning. Drop rejected findings. Nitpicks don't need verification — they're reported, not necessarily fixed.
### Preserve review learnings
After verification, route durable learnings without forcing every review to change policy:
- If an **APPLY** or **APPLY-MODIFIED** finding reveals a recurring or high-risk repository invariant that existing `.greptile/` context and CI do not capture, use `maintain-greptile-rules` and apply its promotion bar.
- Keep one-off bugs as code fixes and regression tests. Put deterministic mechanical checks in CI or lint instead of Greptile.
- When a small tooling, documentation, or workflow frustration occurs, use `papercuts` to append it to `.agents/PAPERCUTS.md`; do not derail merge-ready work to fix it.
## 4. Fix, check, loop
- Apply verified `blocker`/`should-fix` fixes. Apply nitpicks only when trivial and clearly right; otherwise list them in the PR for the maintainer to judge.
- **Checkpoint:** commit fixes in logical groups (e.g. one commit per axis or per concern) so the fix history is reviewable on its own.
- Run `pnpm ci:check` (prettier, knip, tsc, oxlint). Fix failures and re-run until clean. If a fix was substantial (not formatting/lint), run a quick re-review of just that change.
- Loop until ci:check passes and no verified findings remain unaddressed.
## 5. Push and open/update the PR
- Push the branch. Open a PR against `main` if one doesn't exist; otherwise update the existing PR's description.
- PR description requirements:
- **High-level** — what changed and why, written for a human skimming. No file paths, no per-file changelog.
- **How to review** — a short ordered guide: what to look at first, what the risky/judgment-call areas are, what was deliberately left out of scope.
- **Review notes** — unfixed nitpicks and any REJECT verdicts worth a second opinion, clearly labeled as such.
- Report back: PR link, one-paragraph summary, and anything that still needs the maintainer's judgment. Do not merge.

View File

@ -0,0 +1 @@
../../.agents/skills/openseo-release-notes

View File

@ -1,70 +0,0 @@
---
name: openseo-release-notes
description: 'Cut an OpenSEO release — bump the version, draft user-facing release notes from commits since the last tag, run a review + subagent-verification pass, and open a "release: vX.X.X" PR. Use when the user asks to prepare a release, bump the version, or write release notes.'
metadata:
internal: true
---
# OpenSEO release notes
Cut a release for this repo end to end. The deliverables are a version bump in `package.json`, a new `release-notes/v<version>.md`, and a PR against `origin/main` titled `release: v<version>`.
## 1. Bump the version
- Read `package.json`. If the branch has already bumped `version`, treat that as the source of truth and do not change it.
- Otherwise bump the patch version (e.g. `0.0.19``0.0.20`). Only bump minor/major if explicitly asked.
## 2. Collect the changes since the last release
- Find the latest tag: `git tag --sort=-creatordate | head -1`. Verify the branch is up to date with `origin/main` (`git fetch origin main && git log HEAD..origin/main --oneline` should be empty; flag it if not).
- List commits: `git log <last-tag>..HEAD --oneline`. You can also run `pnpm release:notes` for a raw commit inventory — use it only as a checklist of candidate changes, never as the draft's structure (its Improved/Changed/Docs sections must not appear in the notes).
- For each commit, fetch the PR body and author (`gh pr view <num> --repo <repo> --json title,body,author`) — squash-commit subjects can be stale. The `(#NN)` in commit subjects can reference **either** repo: try `bensenescu/open-seo` (origin) first and fall back to `every-app/open-seo` (public) — outside contributors' PRs and their handles live on the public repo. Commits with no `(#NN)` may still be an outside contribution with a public PR (`gh pr list --repo every-app/open-seo --state merged --author <login>`); check `git log --format=%an` for the author. Verify claims against the final code when a PR body and commit subject disagree (features get reverted before merge).
- Record the PR author's GitHub handle alongside each change so the bullet can credit them.
## 3. Draft the notes
Write `release-notes/v<version>.md`. **`release-notes/v0.0.24.md` is the canonical style exemplar** — match it (v0.0.25 and later follow the same style); v0.0.23 and earlier are the old verbose style, never imitate them. The notes are a scannable digest, not documentation: the whole file fits on one screen (roughly 15 lines including headings), and every line earns its place.
Format:
- Top line: a fragment naming the release's 23 highlights ("GSC UI, improved app layout and beta in app agent."). Not a "This release brings…" sentence.
- Sections: `## What's new` and `## Fixed` only. There is no "Improved" section — an improvement is either headline-worthy (What's new) or it's cut.
- **What's new bullets name the feature; they don't sell it.** One short line each ("Redesigned the app layout", "Get GSC Insights inside the app") — no em-dash feature tours, no "so you can…" benefit copy, no lists of everything the feature touches. If the name alone is ambiguous, one clause of plain-words context is the maximum.
- At most **one sub-bullet per feature**, one short line: the single most useful detail, a requirement ("Requires `OPENROUTER_API_KEY`"), or an expectation-setter.
- **Label rough features "(Beta)"** and set expectations honestly, including pointing at the better alternative for now. The expectation-setter rides the top-level line after a dash ("(Beta) In app agent - MCP is still recommended, but we'll be working to improve this during the summer."), keeping the sub-bullet slot free for a requirement or detail.
- Fixed: 35 bullets, one plain sentence each, only bugs a user plausibly hit and would recognize ("Claude answers in AI search work again."). No error codes, status codes, schema/infra vocabulary, or mechanism. If more than four qualify, keep the ones hit in core flows (searches, audits, tracking, MCP answers) and drop fixes for recovering self-inflicted state (re-adding, un-archiving, refreshing) first.
- **Credit the contributor.** End the bullet with `— thanks @handle` for outside contributors only — never for the maintainer's own PRs (`bensenescu`). Credit goes on the top-level bullet, not sub-bullets. Multiple contributors: `— thanks @a, @b`.
- End with: `Full Changelog: https://github.com/every-app/open-seo/compare/v<prev>...v<version>`
Curation — this is where the work is. Cut aggressively; the Full Changelog link covers the long tail:
- Only changes to the **product itself** — the app, the MCP tools, the SEO data/features someone running OpenSEO actually uses. Litmus test per bullet: **would a self-hoster notice this while using the product?** Caring in the abstract (a new backend option, a raised cap) is not enough.
- Do NOT mention:
- **Marketing-website (`web/`) changes** — landing pages, copy, positioning, blog.
- **Pricing / plans / subscription / billing** — paywalls, free-trial/plan changes, Autumn config. Hosted-commercial concerns, irrelevant to self-hosters.
- **Onboarding-flow-only changes** — signup/onboarding chat, profiling steps, upgrade rails, email-verification UX. Not a product capability, even when sizable.
- **Hosted-app internals & meta** — analytics, specs/ADRs, CI, refactors, dependency bumps.
- **Invisible-to-the-user work, even when product-relevant** — security hardening, raised caps/limits, stability/memory/perf fixes, database/backend options and migrations. A user reading the notes should recognize every line from using the product; if they'd only notice it in a config file or an incident that no longer happens, cut it.
- When torn between including and cutting, cut. A 4-bullet What's new that gets read beats a 10-bullet one that doesn't.
- Never invent features — every claim must trace to a commit.
- Numbers in bullets are usually selling — cut them; if one is genuinely load-bearing, quote the conservative, typical figure, never a cherry-picked best case.
## 4. Review and verify
1. Spawn a reviewer subagent with: the draft, the guidelines above, the per-commit facts you gathered, and repo access. It returns numbered review comments citing which guideline each violates. Its charge includes **verbosity**: flag any bullet that sells instead of names, any second sub-bullet, any Fixed bullet with mechanism vocabulary, and anything that pushes the file past one screen.
2. For each substantive comment, spawn a verification subagent (in parallel) that adversarially checks the comment against the actual commits/code and verdicts APPLY / APPLY-MODIFIED / REJECT.
3. Apply only verified comments.
## 5. Open the PR
- Commit the version bump, release notes, and any skill changes on a branch named `claude/v<version>` (use the current branch if it already follows this pattern).
- Push to `origin` and open a PR against `main` titled exactly `release: v<version>`. PR body: the release notes content.
- Do not tag or publish the GitHub release — that happens after merge. After merge, run `pnpm release:publish`. It reads the version from `package.json` and publishes the matching `release-notes/v<version>.md` to `every-app/open-seo`.
- The equivalent command is:
```sh
gh release create v<version> \
--repo every-app/open-seo \
--title v<version> \
--notes-file release-notes/v<version>.md
```

View File

@ -0,0 +1 @@
../../.agents/skills/openseo-review-web-content

View File

@ -1,36 +0,0 @@
---
name: openseo-review-web-content
description: Write and review content for the OpenSEO website (web/) — blog posts, guides, feature pages, FAQs. Distills the philosophy for on-brand, useful, accurate content. Use whenever adding or editing user-facing prose in web/content or web/src.
metadata:
internal: true
---
# OpenSEO Web Content
Everything we publish must be traceable to what the product actually does and costs, and must read like a practitioner wrote it. The reader's interest comes first: teach something they can act on, and answer straight — including when the honest answer is "no" or "it costs money."
## Principles
1. **Traceable truth.** Every capability claim, price, and screenshot is verifiable against the code, the fact sheet (`src/server/features/onboarding/openseo-fact-sheet.md`), or the live product. If you can't point to where it's true, it doesn't ship.
2. **Lead with the real answer.** "No," "not unlimited," and "it costs money" are complete answers. Hedging that lets a reader infer something more flattering than the truth is a way of misleading them.
3. **Honest pricing, with its reasoning.** Quality SEO data is expensive everywhere — that's why the big suites run $100/month and up. OpenSEO is the affordable option: $10/month, free to start. Never simply "free."
4. **Sound like a person.** Fix AI tells by restating the underlying claim plainly, not by polishing the flourish. The [deslop skill](../deslop/SKILL.md) is the reference for what to hunt and how to fix it.
5. **Reader-first altitude.** Guides teach actionable SEO that stands on its own — not product documentation, not generic filler. Credit free resources to their real owners (Google's autocomplete, the reader's own Search Console).
6. **One bar, whole surface.** When a standard improves, sweep everything to it — all the FAQs, all the pages — not just the instance that got noticed.
## Questions to ask while reviewing
- If a reader trusted every claim and screenshot, then opened OpenSEO right now, where would reality not match?
- Does each answer open with the real answer, or quietly steer toward a more flattering inference?
- Read the sharpest line aloud: would a person say it that way?
- Is anything called free that actually costs credits?
- Is this teaching the reader something useful on its own, or drifting into product docs or padding?
- Does every link, image, and example on the page earn its place for the reader?
## Facts to verify, not remember
Check these against code before repeating any of them — they change: pricing and credits (`src/shared/billing.ts`, the pricing page), free-plan limits (`src/shared/audit-limits.ts`), MCP capabilities (`src/server/mcp/tools/` — one file per tool), and any UI affordance copy tells the reader to use (the column, sort, or filter must exist in the client code).
## Process
Spawn subagents to run the review passes (voice/deslop, claims accuracy, directness) and have them return exact old → new proposals rather than editing directly. Do not accept their proposals blindly: verify each one against the actual file, and each factual claim against the code, before applying — subagent rewrites can introduce their own awkwardness or errors, and a proposal that mismatches the file means it reviewed stale text. After applying, sweep the changed surface yourself (patterns cluster — one em dash or hedge usually has neighbors), then run `npm --prefix web run types:check` and prettier on touched TS/TSX.

1
.claude/skills/papercuts Symbolic link
View File

@ -0,0 +1 @@
../../.agents/skills/papercuts

View File

@ -1,81 +0,0 @@
---
name: papercuts
description: "Log genuine, recurring repository friction to .agents/PAPERCUTS.md — confusing setup, a flaky repo command or script, a misleading in-repo error, stale generated files, or a non-obvious gotcha that will cost the next contributor time. Also use to review, deduplicate, and resolve existing entries. Gate hard before logging: only friction the repository itself can fix counts. Never log the agent's own sandbox/permission errors, shell-scripting mistakes, transient flakiness, or third-party tool quirks the repo can't change."
metadata:
internal: true
---
# Papercuts
Capture small friction in the moment without derailing the current task.
Aggregated entries show where the repository needs sanding down — so the bar is
that a _different_ contributor would hit the same thing, and the _repository_
can do something about it.
## The two-question test
Log it only if **both** are true:
1. **Reproducible for anyone.** A different person, on a fresh checkout, working
in this repo would hit the same friction. It is not specific to your sandbox,
shell config, machine, network, or a one-time hiccup.
2. **Fixable in the repo.** A change to the repo's code, config, scripts, or
docs would prevent or reduce it.
If either answer is "no," push through it and move on — do not log it.
## Do NOT log
- **Your environment's failures.** Sandbox `EPERM` / `listen` / IPC-socket
errors, blocked network or `fetch failed`, permission denials, missing system
tools. That is the runner, not the repo.
- **Your own shell mistakes.** Reserved or special variable names (`status`,
`path`), unquoted globs, a broken login-shell hook. Fix the command — there is
nothing in the repo to sand down.
- **Transient flakiness.** A command that succeeded on retry with no repo-side
cause (a network blip, a hung push, a slow mirror).
- **Local state you corrupted.** A partial `node_modules` after branch-switching,
a stale dev-server port, a dirty cache. Re-run the install or cleanup.
- **Third-party or beta-tool limitations the repo can't change** — unless the
fix is a repo-side workaround worth writing down (then log _that_ workaround).
- Product or code correctness bugs (fix now or track as real work), and what you
accomplished (that belongs in the task summary).
- Secrets, credentials, personal data, raw customer payloads, or sensitive paths.
When something fails, first ask "is this the repo, or is this me/my environment?"
Only the former is a papercut.
## Log proactively
1. Search `.agents/PAPERCUTS.md` for an equivalent entry and avoid duplicates.
2. Append one unchecked item under `## Open` using this format:
```markdown
- [ ] `YYYY-MM-DDTHH:MM:SSZ``agent`<friction, and the smallest useful fix or workaround>.
```
3. Keep it to one or two sentences: what got in the way, and the likely repo-side
fix. Lead with the friction, not with what you were doing.
4. Continue the original task. Do not expand a papercut into unrelated work.
Use UTC timestamps and a short agent label (`codex`, `claude`, `human`). Add a
PR or task identifier only when it helps future triage.
## Review or resolve
Only mine a whole session or do a broad review when the user explicitly asks.
When asked to review the file:
1. Re-run the two-question test on every open entry; delete any that fail it
(environment/shell/flake noise that slipped in).
2. Deduplicate and group related entries.
3. Verify each surviving papercut still reproduces.
4. Fix the smallest safe, high-leverage entries first.
5. Move fixed items to `## Resolved`, check them, and append the resolving date
or commit. Route real bugs to normal issue/fix work; route recurring
review-policy gaps through `maintain-greptile-rules`.
Preserve useful history for genuinely-resolved papercuts; do not delete them
merely to make the file shorter. (Noise that never belonged — see step 1 — is
different: remove it.)

View File

@ -0,0 +1 @@
../../.agents/skills/verify-local-mcp

View File

@ -126,6 +126,34 @@ const toolCategories: ToolCategory[] = [
title: "Get business questions",
description: "Read Google Business Profile Q&A rows.",
},
{
name: "get_business_profile",
title: "Get business profile",
description:
"Audit a Google Business Profile's categories, rating, hours, and claim status.",
},
{
name: "get_business_reviews",
title: "Get business reviews",
description:
"Collect Google reviews, including owner replies and other-site sources.",
},
{
name: "get_business_updates",
title: "Get business updates",
description: "Check posting activity on a Google Business Profile.",
},
{
name: "list_business_categories",
title: "List business categories",
description: "Find valid Google Business category slugs.",
},
{
name: "get_local_rank_grid",
title: "Get local rank grid",
description:
"Check Google Maps rank at each point of a grid around a business.",
},
],
},
{

View File

@ -19,6 +19,13 @@ import {
getRankedKeywordsTool,
searchLocalBusinessesTool,
} from "@/server/mcp/tools/dataforseo-research-tools";
import {
getBusinessProfileTool,
getBusinessReviewsTool,
getBusinessUpdatesTool,
getLocalRankGridTool,
listBusinessCategoriesTool,
} from "@/server/mcp/tools/local-seo-tools";
import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords";
import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords";
import {
@ -217,6 +224,11 @@ export function buildSamMcpTools(
search_local_businesses: adaptTool(searchLocalBusinessesTool),
get_local_serp_results: adaptTool(getLocalSerpResultsTool),
get_google_business_questions: adaptTool(getGoogleBusinessQuestionsTool),
get_business_profile: adaptTool(getBusinessProfileTool),
get_business_reviews: adaptTool(getBusinessReviewsTool),
get_business_updates: adaptTool(getBusinessUpdatesTool),
list_business_categories: adaptTool(listBusinessCategoriesTool),
get_local_rank_grid: adaptTool(getLocalRankGridTool),
get_keyword_metrics: adaptTool(getKeywordMetricsTool),
get_search_console_performance: adaptTool(getSearchConsolePerformanceTool),
inspect_urls: adaptTool(inspectUrlsTool),

View File

@ -0,0 +1,301 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/server/lib/runtime-env", () => ({
getRequiredEnvValue: vi.fn(async () => "test-api-key"),
}));
import {
fetchBusinessDataTaskResult,
fetchBusinessListingsCategories,
fetchBusinessListingsSearch,
fetchMyBusinessInfo,
postGoogleReviewsTask,
} from "@/server/lib/dataforseo/business";
function stubDataforseo(payload: unknown) {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValue(Response.json(payload));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function requestOf(fetchMock: ReturnType<typeof stubDataforseo>) {
const [url, init] = fetchMock.mock.calls[0];
const rawUrl = typeof url === "string" || url instanceof URL ? url : url.url;
const body = init?.body;
return {
url: rawUrl.toString(),
body: typeof body === "string" ? (JSON.parse(body) as unknown) : null,
};
}
const okTask = (path: string[], result: unknown[]) => ({
status_code: 20000,
tasks: [{ status_code: 20000, path, cost: 0.002, result }],
});
describe("Google business_data fetchers", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("sends a coordinate for my_business_info and returns the single item", async () => {
const fetchMock = stubDataforseo(
okTask(
["v3", "business_data", "google", "my_business_info", "live"],
[{ items: [{ title: "Acme Cafe", is_claimed: true }] }],
),
);
const result = await fetchMyBusinessInfo({
keyword: "cid:123",
locationCoordinate: "33.1234568,-84.9876543,5000",
locationCode: 2840,
languageCode: "en",
});
const { url, body } = requestOf(fetchMock);
expect(url).toBe(
"https://api.dataforseo.com/v3/business_data/google/my_business_info/live",
);
// The coordinate wins: DataForSEO rejects a request carrying both.
expect(body).toEqual([
{
keyword: "cid:123",
location_coordinate: "33.1234568,-84.9876543,5000",
language_code: "en",
},
]);
expect(result.data).toEqual({ title: "Acme Cafe", is_claimed: true });
expect(result.billing.costUsd).toBe(0.002);
});
it("falls back to location_code and treats no-results as an empty success", async () => {
const fetchMock = stubDataforseo({
status_code: 20000,
tasks: [
{
status_code: 40501,
status_message: "No Search Results.",
path: ["v3", "business_data", "google", "my_business_info", "live"],
cost: 0.002,
},
],
});
const result = await fetchMyBusinessInfo({
keyword: "Nowhere Cafe",
locationCode: 2840,
languageCode: "en",
});
expect(requestOf(fetchMock).body).toEqual([
{
keyword: "Nowhere Cafe",
location_code: 2840,
language_code: "en",
},
]);
expect(result.data).toBeNull();
// DataForSEO charges for an empty result, so it still has to be metered.
expect(result.billing.costUsd).toBe(0.002);
});
it("posts regular reviews with sort_by and bills from the post entry", async () => {
const fetchMock = stubDataforseo({
status_code: 20000,
tasks: [
{
id: "task-1",
status_code: 20100,
cost: 0.00375,
path: ["v3", "business_data", "google", "reviews", "task_post"],
},
],
});
const result = await postGoogleReviewsTask({
cid: "123",
locationCode: 2840,
languageCode: "en",
depth: 20,
sortBy: "newest",
includeOtherSources: false,
});
const { url, body } = requestOf(fetchMock);
expect(url).toBe(
"https://api.dataforseo.com/v3/business_data/google/reviews/task_post",
);
expect(body).toEqual([
{
cid: "123",
location_code: 2840,
language_code: "en",
depth: 20,
sort_by: "newest",
priority: 2,
},
]);
expect(result).toEqual({
data: "task-1",
billing: {
path: ["v3", "business_data", "google", "reviews", "task_post"],
costUsd: 0.00375,
},
});
});
it("posts to the extended_reviews endpoint when other sources are requested", async () => {
const fetchMock = stubDataforseo({
status_code: 20000,
tasks: [
{
id: "task-2",
status_code: 20100,
cost: 0.01,
path: [
"v3",
"business_data",
"google",
"extended_reviews",
"task_post",
],
},
],
});
const result = await postGoogleReviewsTask({
cid: "123",
locationCode: 2840,
languageCode: "en",
depth: 20,
// The extended endpoint has no sort_by; the fetcher drops it.
sortBy: "newest",
includeOtherSources: true,
});
const { url, body } = requestOf(fetchMock);
expect(url).toBe(
"https://api.dataforseo.com/v3/business_data/google/extended_reviews/task_post",
);
expect(body).toEqual([
{
cid: "123",
location_code: 2840,
language_code: "en",
depth: 20,
priority: 2,
},
]);
expect(result.data).toBe("task-2");
expect(result.billing.path).toEqual([
"v3",
"business_data",
"google",
"extended_reviews",
"task_post",
]);
});
it("never replays a task_post on a 5xx (a retry could double-charge)", async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response("upstream error", { status: 500 }));
vi.stubGlobal("fetch", fetchMock);
await expect(
postGoogleReviewsTask({
cid: "123",
locationCode: 2840,
languageCode: "en",
depth: 20,
sortBy: "newest",
includeOtherSources: false,
}),
).rejects.toMatchObject({ code: "UPSTREAM_UNAVAILABLE" });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("reports a queued task as pending instead of failing", async () => {
stubDataforseo({
status_code: 20000,
tasks: [{ status_code: 40602, status_message: "Task In Queue." }],
});
await expect(
fetchBusinessDataTaskResult({ endpoint: "reviews", taskId: "task-1" }),
).resolves.toEqual({ status: "pending", result: null });
});
it("returns the first result once the task completed", async () => {
const fetchMock = stubDataforseo(
okTask(
["v3", "business_data", "google", "extended_reviews", "task_get"],
[{ reviews_count: 12, items: [{ review_text: "Great" }] }],
),
);
const outcome = await fetchBusinessDataTaskResult({
endpoint: "extended_reviews",
taskId: "task-9",
});
expect(requestOf(fetchMock).url).toBe(
"https://api.dataforseo.com/v3/business_data/google/extended_reviews/task_get/task-9",
);
expect(outcome).toEqual({
status: "completed",
result: { reviews_count: 12, items: [{ review_text: "Great" }] },
});
});
it("maps the free categories list onto category/businessCount rows", async () => {
stubDataforseo(
okTask(
["v3", "business_data", "business_listings", "categories"],
[
{ category_name: "pizza_restaurant", business_count: 12 },
{ category_name: "plumber" },
],
),
);
const result = await fetchBusinessListingsCategories();
expect(result.data).toEqual([
{ category: "pizza_restaurant", businessCount: 12 },
{ category: "plumber", businessCount: null },
]);
});
it("forwards business-listing filters, claim status, and offset", async () => {
const fetchMock = stubDataforseo(
okTask(
["v3", "business_data", "business_listings", "search", "live"],
[{ items: [] }],
),
);
await fetchBusinessListingsSearch({
locationCoordinate: "33.1,-84.9,5",
isClaimed: false,
filters: [["rating.value", ">=", 4]],
orderBy: ["rating.value,desc"],
limit: 20,
offset: 10,
});
expect(requestOf(fetchMock).body).toEqual([
{
location_coordinate: "33.1,-84.9,5",
is_claimed: false,
filters: [["rating.value", ">=", 4]],
order_by: ["rating.value,desc"],
limit: 20,
offset: 10,
},
]);
});
});

View File

@ -1,32 +1,68 @@
import { z } from "zod";
import {
BusinessDataBusinessListingsSearchLiveRequestInfo,
BusinessDataGoogleExtendedReviewsTaskPostRequestInfo,
BusinessDataGoogleMyBusinessInfoLiveRequestInfo,
BusinessDataGoogleMyBusinessUpdatesTaskPostRequestInfo,
BusinessDataGoogleQuestionsAndAnswersLiveRequestInfo,
BusinessDataGoogleReviewsTaskPostRequestInfo,
type BusinessDataBusinessListingsSearchLiveItem,
} from "dataforseo-client";
import { businessDataApi } from "@/server/lib/dataforseo/core";
import {
businessDataApi,
businessDataTaskApi,
} from "@/server/lib/dataforseo/core";
import {
assertOk,
buildTaskBilling,
isNoResultsTask,
isRecord,
isTaskInProgress,
type DataforseoApiResponse,
type DataforseoResponseLike,
type DataforseoTaskLike,
} from "@/server/lib/dataforseo/envelope";
import { AppError } from "@/server/lib/errors";
type BusinessListingItem = BusinessDataBusinessListingsSearchLiveItem;
/**
* Location + language for the Google business_data endpoints. They accept
* either a coordinate ("lat,lng,radius" in meters) or a location code, never
* both, so the coordinate wins when present.
*/
type BusinessLocationInput = {
locationCoordinate?: string;
locationCode?: number;
languageCode: string;
};
function locationParams(input: BusinessLocationInput) {
return input.locationCoordinate
? { location_coordinate: input.locationCoordinate }
: { location_code: input.locationCode };
}
export async function fetchBusinessListingsSearch(input: {
categories?: string[];
title?: string;
locationCoordinate: string;
isClaimed?: boolean;
filters?: unknown[];
orderBy?: string[];
limit: number;
offset?: number;
}): Promise<DataforseoApiResponse<BusinessListingItem[]>> {
const response = await businessDataApi().businessListingsSearchLive([
new BusinessDataBusinessListingsSearchLiveRequestInfo({
categories: input.categories,
title: input.title,
location_coordinate: input.locationCoordinate,
is_claimed: input.isClaimed,
filters: input.filters,
order_by: input.orderBy,
limit: input.limit,
offset: input.offset,
}),
]);
// "No Search Results" (40501) is a valid empty result for obscure
@ -87,3 +123,201 @@ export async function fetchQuestionsAnswers(input: {
billing: buildTaskBilling(task),
};
}
export async function fetchMyBusinessInfo(
input: { keyword: string } & BusinessLocationInput,
): Promise<DataforseoApiResponse<Record<string, unknown> | null>> {
const response = await businessDataApi().googleMyBusinessInfoLive([
new BusinessDataGoogleMyBusinessInfoLiveRequestInfo({
keyword: input.keyword,
...locationParams(input),
language_code: input.languageCode,
}),
]);
// 40501 = billed empty result: a business Google has no profile for.
const task = assertOk(response, { treatNoResultsAsEmpty: true });
const entry = task.result?.[0];
const item = entry?.items?.[0];
if (!isRecord(item)) {
return { data: null, billing: buildTaskBilling(task) };
}
// check_url (the Google Maps link DataForSEO verified against) lives on the
// result entry, not the item — merge it so callers get one complete record.
if (item.check_url == null) item.check_url = entry?.check_url;
return { data: item, billing: buildTaskBilling(task) };
}
// ---------------------------------------------------------------------------
// Task-queue business data (reviews, extended reviews, profile updates).
// DataForSEO bills these at task_post; task_get collection is free. The post
// fetchers therefore run through the metered client while
// fetchBusinessDataTaskResult deliberately does not — see index.ts.
// ---------------------------------------------------------------------------
/** High execution priority: reviews normally settle within ~20s instead of
* minutes, which is what makes a single MCP call able to return them. */
const TASK_PRIORITY_HIGH = 2;
/**
* Validates a task_post response and returns the created task's id. `assertOk`
* applies the standard charged-failure ladder (a rejected post entry still
* carries the cost DataForSEO charged, and "Invalid Field" rejections stay
* non-reportable); 20100 "Task Created" is the success status for posts.
*/
function postedTaskId<T extends DataforseoTaskLike & { id?: string }>(
response: DataforseoResponseLike<T> | null,
): DataforseoApiResponse<string> {
const task = assertOk(response, { okTaskStatusCode: 20100 });
if (!task.id) {
throw new AppError("INTERNAL_ERROR", "DataForSEO did not return a task id");
}
return { data: task.id, billing: buildTaskBilling(task) };
}
export type BusinessTaskEndpoint =
| "reviews"
| "extended_reviews"
| "my_business_updates";
type BusinessIdentifierInput = {
keyword?: string;
cid?: string;
placeId?: string;
};
export async function postGoogleReviewsTask(
input: BusinessIdentifierInput &
BusinessLocationInput & {
depth: number;
/** Only the regular reviews endpoint supports sorting. */
sortBy?: string;
/** Collect reviews Google surfaces from other sites (Yelp, Tripadvisor). */
includeOtherSources: boolean;
},
): Promise<DataforseoApiResponse<string>> {
if (input.includeOtherSources) {
return postedTaskId(
await businessDataTaskApi().googleExtendedReviewsTaskPost([
new BusinessDataGoogleExtendedReviewsTaskPostRequestInfo({
keyword: input.keyword,
cid: input.cid,
place_id: input.placeId,
...locationParams(input),
language_code: input.languageCode,
depth: input.depth,
priority: TASK_PRIORITY_HIGH,
}),
]),
);
}
return postedTaskId(
await businessDataTaskApi().googleReviewsTaskPost([
new BusinessDataGoogleReviewsTaskPostRequestInfo({
keyword: input.keyword,
cid: input.cid,
place_id: input.placeId,
...locationParams(input),
language_code: input.languageCode,
depth: input.depth,
sort_by: input.sortBy,
priority: TASK_PRIORITY_HIGH,
}),
]),
);
}
export async function postMyBusinessUpdatesTask(
input: { keyword: string; depth: number } & BusinessLocationInput,
): Promise<DataforseoApiResponse<string>> {
return postedTaskId(
await businessDataTaskApi().googleMyBusinessUpdatesTaskPost([
new BusinessDataGoogleMyBusinessUpdatesTaskPostRequestInfo({
keyword: input.keyword,
...locationParams(input),
language_code: input.languageCode,
depth: input.depth,
priority: TASK_PRIORITY_HIGH,
}),
]),
);
}
export type BusinessTaskOutcome = {
status: "pending" | "completed";
/** The first `result` entry once the task completed; null when empty. */
result: Record<string, unknown> | null;
};
/**
* Collects one queued business_data task. Deliberately not metered and not
* wrapped in the billing envelope: collection is free (the task was charged at
* task_post), so routing it through the metering seam would charge twice.
*/
export async function fetchBusinessDataTaskResult(input: {
endpoint: BusinessTaskEndpoint;
taskId: string;
}): Promise<BusinessTaskOutcome> {
const api = businessDataApi();
const response =
input.endpoint === "reviews"
? await api.googleReviewsTaskGet(input.taskId)
: input.endpoint === "extended_reviews"
? await api.googleExtendedReviewsTaskGet(input.taskId)
: await api.googleMyBusinessUpdatesTaskGet(input.taskId);
const task = response?.tasks?.[0];
if (!response || response.status_code !== 20000 || !task) {
throw new AppError(
"INTERNAL_ERROR",
response?.status_message || "DataForSEO task_get failed",
);
}
if (isTaskInProgress(task)) return { status: "pending", result: null };
if (task.status_code !== 20000) {
// "No Search Results" is a valid empty outcome (no reviews/updates yet).
if (!isNoResultsTask(task)) {
throw new AppError(
"INTERNAL_ERROR",
task.status_message || `DataForSEO task failed (${task.status_code})`,
);
}
return { status: "completed", result: null };
}
const first = task.result?.[0];
return { status: "completed", result: isRecord(first) ? first : null };
}
const businessCategorySchema = z
.object({
category_name: z.string(),
business_count: z.number().nullable().optional(),
})
.passthrough();
type BusinessCategoryRow = {
category: string;
businessCount: number | null;
};
/** Top Business Listings categories by business count. Free at DataForSEO. */
export async function fetchBusinessListingsCategories(): Promise<
DataforseoApiResponse<BusinessCategoryRow[]>
> {
const response = await businessDataApi().businessListingsCategories();
const task = assertOk(response);
// This endpoint puts rows directly on `result` rather than `result[0].items`.
const rows = (task.result ?? []).flatMap((entry) => {
const parsed = businessCategorySchema.safeParse(entry);
if (!parsed.success) return [];
return [
{
category: parsed.data.category_name,
businessCount: parsed.data.business_count ?? null,
},
];
});
return { data: rows, billing: buildTaskBilling(task) };
}

View File

@ -73,6 +73,23 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
(s) => s.fetchQuestionsAnswers,
"local_seo",
),
myBusinessInfo: meter(
customer,
(s) => s.fetchMyBusinessInfo,
"local_seo",
),
// task_post is where DataForSEO charges; collection runs unmetered
// through fetchBusinessDataTaskResult (see index.ts).
reviewsTaskPost: meter(
customer,
(s) => s.postGoogleReviewsTask,
"local_seo",
),
updatesTaskPost: meter(
customer,
(s) => s.postMyBusinessUpdatesTask,
"local_seo",
),
},
backlinks: {
summary: meter(customer, (s) => s.fetchBacklinksSummary),

View File

@ -132,6 +132,10 @@ export const labsApi = () => new DataforseoLabsApi(API_BASE, http());
export const keywordsDataApi = () => new KeywordsDataApi(API_BASE, http());
export const serpApi = () => new SerpApi(API_BASE, http());
export const businessDataApi = () => new BusinessDataApi(API_BASE, http());
// task_post creates a billed task. A 5xx does not prove the provider skipped
// the charge, so this client must not replay it (same rule as Lighthouse).
export const businessDataTaskApi = () =>
new BusinessDataApi(API_BASE, http(undefined, 0));
// Lighthouse live is a billed, non-idempotent POST. A 5xx does not prove the
// provider skipped the charge, so this client must not replay it.
export const onPageApi = () => new OnPageApi(API_BASE, http(undefined, 0));

View File

@ -60,7 +60,7 @@ export interface DataforseoTaskLike {
[key: string]: unknown;
}
interface DataforseoResponseLike<T extends DataforseoTaskLike> {
export interface DataforseoResponseLike<T extends DataforseoTaskLike> {
status_code?: number;
status_message?: string;
tasks?: T[];
@ -123,6 +123,17 @@ export function isNoResultsTask(task: DataforseoTaskLike): boolean {
);
}
/** Task lifecycle codes meaning "not done yet": Task Created / Task Handed /
* Task In Queue. A task_get returning one of these is pending, not failed. */
const TASK_IN_PROGRESS_STATUS_CODES = new Set([20100, 40601, 40602]);
export function isTaskInProgress(task: DataforseoTaskLike): boolean {
return (
task.status_code !== undefined &&
TASK_IN_PROGRESS_STATUS_CODES.has(task.status_code)
);
}
type AssertOkOptions = {
/** Maps a recognised access / billing failure to a product error. */
classify?: DataforseoErrorClassifier;
@ -130,6 +141,9 @@ type AssertOkOptions = {
classifyPath?: string;
/** Treat DataForSEO's "no search results" (40501) as an empty success. */
treatNoResultsAsEmpty?: boolean;
/** Task status that counts as success. Live endpoints return 20000; task_post
* entries return 20100 "Task Created". */
okTaskStatusCode?: number;
};
/**
@ -149,7 +163,8 @@ export function assertOk<T extends DataforseoTaskLike>(
"DataForSEO returned an empty response",
);
}
const { classify, classifyPath, treatNoResultsAsEmpty } = options;
const { classify, classifyPath, treatNoResultsAsEmpty, okTaskStatusCode } =
options;
if (response.status_code !== 20000) {
const message = response.status_message || "DataForSEO request failed";
@ -164,7 +179,7 @@ export function assertOk<T extends DataforseoTaskLike>(
throw new AppError("INTERNAL_ERROR", "DataForSEO response missing task");
}
if (task.status_code !== 20000) {
if (task.status_code !== (okTaskStatusCode ?? 20000)) {
if (treatNoResultsAsEmpty && isNoResultsTask(task)) return task;
const message = task.status_message || "DataForSEO task failed";

View File

@ -28,12 +28,28 @@ export {
export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
/** Lazy wrapper for the one section fetcher called outside the metered client
* (rank-check task collection is free at DataForSEO, so it skips metering). */
/** Lazy wrappers for the section fetchers called outside the metered client.
* Task collection is free at DataForSEO (the task was charged at task_post), so
* routing these through the metering seam would charge the customer twice. */
export const fetchRankCheckTaskResult: DataforseoSections["fetchRankCheckTaskResult"] =
async (input) =>
(await loadDataforseoSections()).fetchRankCheckTaskResult(input);
export const fetchBusinessDataTaskResult: DataforseoSections["fetchBusinessDataTaskResult"] =
async (input) =>
(await loadDataforseoSections()).fetchBusinessDataTaskResult(input);
/** Free ($0) at DataForSEO, so it skips the metered client entirely a
* zero-credit org can still list categories. */
export const fetchBusinessListingsCategories: DataforseoSections["fetchBusinessListingsCategories"] =
async () =>
(await loadDataforseoSections()).fetchBusinessListingsCategories();
export type {
BusinessTaskEndpoint,
BusinessTaskOutcome,
} from "@/server/lib/dataforseo/business";
export type {
LabsKeywordDataItem,
DomainRankedKeywordItem,

View File

@ -7,8 +7,13 @@
// re-enters the eager graph. SDK-free values live in shared.ts instead.
export {
fetchBusinessDataTaskResult,
fetchBusinessListingsCategories,
fetchBusinessListingsSearch,
fetchMyBusinessInfo,
fetchQuestionsAnswers,
postGoogleReviewsTask,
postMyBusinessUpdatesTask,
} from "@/server/lib/dataforseo/business";
export {

View File

@ -12,6 +12,7 @@ import {
assertOk,
buildTaskBilling,
isNoResultsTask,
isTaskInProgress,
parseTaskItems,
type DataforseoApiResponse,
} from "@/server/lib/dataforseo/envelope";
@ -282,10 +283,6 @@ type RankCheckTaskOutcome =
| { status: "failed"; message: string }
| { status: "completed"; result: RankCheckResult };
// Task lifecycle codes meaning "not done yet": Task Created / Task Handed /
// Task In Queue.
const TASK_IN_PROGRESS_STATUS_CODES = new Set([20100, 40601, 40602]);
/**
* Collect one queued task's result. Deliberately not metered and not wrapped
* in the billing envelope: collection is free (the task was charged at
@ -308,10 +305,7 @@ export async function fetchRankCheckTaskResult(input: {
);
}
if (
task.status_code !== undefined &&
TASK_IN_PROGRESS_STATUS_CODES.has(task.status_code)
) {
if (isTaskInProgress(task)) {
return { status: "pending" };
}
@ -364,7 +358,9 @@ export async function fetchLocalSerp(input: {
search_places: input.searchPlaces,
}),
]);
const task = assertOk(response);
// 40501 = billed empty SERP; DataForSEO returns it for some coordinate-only
// Maps and Local Finder queries (both paths below opt in).
const task = assertOk(response, { treatNoResultsAsEmpty: true });
return {
data: task.result?.[0]?.items ?? [],
billing: buildTaskBilling(task),
@ -381,7 +377,7 @@ export async function fetchLocalSerp(input: {
depth: input.depth,
}),
]);
const task = assertOk(response);
const task = assertOk(response, { treatNoResultsAsEmpty: true });
return {
data: task.result?.[0]?.items ?? [],
billing: buildTaskBilling(task),

View File

@ -45,6 +45,13 @@ import {
getRankedKeywordsTool,
searchLocalBusinessesTool,
} from "@/server/mcp/tools/dataforseo-research-tools";
import {
getBusinessProfileTool,
getBusinessReviewsTool,
getBusinessUpdatesTool,
getLocalRankGridTool,
listBusinessCategoriesTool,
} from "@/server/mcp/tools/local-seo-tools";
import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords";
import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords";
import {
@ -120,7 +127,7 @@ export function createOpenSeoMcpServer(authProps: McpProps) {
{
name: "OpenSEO MCP",
title: "OpenSEO",
version: "0.0.11",
version: "0.0.12",
description:
"SEO research tools for AI agents: keyword research and metrics, SERP and local SERP results, domain and backlink analysis, rank tracking, and Google Search Console performance.",
websiteUrl: "https://openseo.so",
@ -164,6 +171,11 @@ export function createOpenSeoMcpServer(authProps: McpProps) {
register(searchLocalBusinessesTool);
register(getLocalSerpResultsTool);
register(getGoogleBusinessQuestionsTool);
register(getBusinessProfileTool);
register(getBusinessReviewsTool);
register(getBusinessUpdatesTool);
register(listBusinessCategoriesTool);
register(getLocalRankGridTool);
register(getKeywordMetricsTool);
register(getSearchConsolePerformanceTool);
register(inspectUrlsTool);

View File

@ -31,6 +31,16 @@ export function formatMcpCell(value: unknown): string {
}
}
/** Column `format` that truncates long text after normal cell formatting, so
* provider prose can't dominate the table (full text stays in
* structuredContent). */
export function truncatedCell(maxLength: number) {
return (value: unknown): string => {
const cell = formatMcpCell(value);
return cell.length > maxLength ? `${cell.slice(0, maxLength - 1)}` : cell;
};
}
/** Render rows as a `header | header` table with one line per row. */
export function formatMcpTable<T>(
rows: readonly T[],

View File

@ -44,11 +44,15 @@ describe("DataForSEO research MCP tools", () => {
mocks.getProjectForOrganization.mockResolvedValue(usProjectRow);
});
it("searches local businesses without running rankings or Q&A", async () => {
const businessListings = vi
.fn()
.mockResolvedValue([
{ title: "Acme Cafe", url: "https://acme-cafe.example" },
it("searches local businesses, rounding fractional radii and trimming rows", async () => {
const businessListings = vi.fn().mockResolvedValue([
{
title: "Acme Cafe",
url: "https://acme-cafe.example",
// Bulky fields that overflow MCP clients must not reach the response.
popular_times: { monday: [] },
attributes: { available_attributes: {} },
},
]);
const local = vi.fn();
const questionsAnswers = vi.fn();
@ -57,47 +61,80 @@ describe("DataForSEO research MCP tools", () => {
business: { businessListings, questionsAnswers },
serp: { local },
});
const { searchLocalBusinessesTool } = researchTools;
const result = await searchLocalBusinessesTool.handler(
const result = await researchTools.searchLocalBusinessesTool.handler(
{
projectId: "project_1",
query: "Acme Cafe",
near: {
latitude: 33.123456789,
longitude: -84.987654321,
radiusKm: 5,
radiusKm: 1.5,
},
categories: ["cafe"],
},
toolContext,
);
// Business Listings rejects fractional radii: 1.5 km rounds to 2.
expect(businessListings).toHaveBeenCalledWith(
expect.objectContaining({
locationCoordinate: "33.1234568,-84.9876543,5",
locationCoordinate: "33.1234568,-84.9876543,2",
categories: ["cafe"],
}),
);
expect(local).not.toHaveBeenCalled();
expect(questionsAnswers).not.toHaveBeenCalled();
const content = z
.object({ businesses: z.array(z.object({ title: z.string() })) })
.passthrough()
.parse(result.structuredContent);
expect(content.businesses).toEqual([{ title: "Acme Cafe" }]);
expect(result.structuredContent.businesses).toEqual([
{ title: "Acme Cafe", url: "https://acme-cafe.example" },
]);
expect(textContent(result)).toContain("title | category");
expect(textContent(result)).toContain("Acme Cafe");
});
it("fetches one local SERP with search_places disabled", async () => {
it("maps local business rating/review/claim filters onto the provider call", async () => {
const businessListings = vi.fn().mockResolvedValue([]);
mocks.createDataforseoClient.mockReturnValue({
business: { businessListings },
});
await researchTools.searchLocalBusinessesTool.handler(
{
projectId: "project_1",
near: { latitude: 33, longitude: -84, radiusKm: 5 },
minRating: 4,
minReviews: 25,
isClaimed: false,
sortBy: "reviews",
offset: 20,
},
toolContext,
);
expect(businessListings).toHaveBeenCalledWith(
expect.objectContaining({
isClaimed: false,
filters: [
["rating.value", ">=", 4],
"and",
["rating.votes_count", ">=", 25],
],
orderBy: ["rating.votes_count,desc"],
offset: 20,
}),
);
});
it("fetches one local SERP with search_places disabled and trims rows", async () => {
const local = vi.fn().mockResolvedValue([
{
type: "maps_search",
title: "Acme Cafe",
rank_group: 1,
rank_absolute: 2,
// Dead-weight provider fields must not reach the response.
main_image: "https://lh3.example/huge",
feature_id: "0xabc:0xdef",
},
]);
@ -124,22 +161,17 @@ describe("DataForSEO research MCP tools", () => {
locationCoordinate: "33.1234568,-84.9876543,14z",
searchPlaces: false,
searchType: "maps",
device: "desktop",
device: "mobile",
}),
);
const content = z
.object({
results: z.array(
z.object({ rank_group: z.number(), rank_absolute: z.number() }),
),
})
.object({ results: z.array(z.object({}).passthrough()) })
.passthrough()
.parse(result.structuredContent);
expect(content.results[0]).toMatchObject({
rank_group: 1,
rank_absolute: 2,
});
expect(content.results).toEqual([
{ title: "Acme Cafe", rank_group: 1, rank_absolute: 2 },
]);
expect(textContent(result)).toContain("rank | title | rating");
expect(textContent(result)).toContain("Acme Cafe");
});
@ -157,7 +189,7 @@ describe("DataForSEO research MCP tools", () => {
const result = await getGoogleBusinessQuestionsTool.handler(
{
projectId: "project_1",
keyword: "Acme Cafe",
cid: "123",
near: {
latitude: 33.123456789,
longitude: -84.987654321,
@ -169,7 +201,8 @@ describe("DataForSEO research MCP tools", () => {
expect(questionsAnswers).toHaveBeenCalledWith(
expect.objectContaining({
keyword: "Acme Cafe",
// The identifier trio rides the shared cid:/place_id: prefixes.
keyword: "cid:123",
locationCoordinate: "33.1234568,-84.9876543,5000",
}),
);

View File

@ -17,6 +17,15 @@ import {
readPath,
type McpTableColumn,
} from "@/server/mcp/table";
import {
businessIdentifierInputSchema,
businessIdentifierKeyword,
formatBusinessDataCoordinate,
formatCoordinate,
formatLocalSerpCoordinate,
pickRowFields,
resolveBusinessIdentifier,
} from "@/server/mcp/tools/local-seo-shared";
import { resolveLabsMarket, resolveMarket } from "@/shared/keyword-locations";
import {
assertLabsLocationCode,
@ -84,7 +93,9 @@ const nearSchema = z
.number()
.min(1)
.max(100000)
.describe("Search radius around the center, in kilometers."),
.describe(
"Search radius around the center, in whole kilometers (fractions are rounded).",
),
})
.describe("Coordinate and radius to search around.");
@ -216,6 +227,28 @@ const searchLocalBusinessesInputSchema = {
.max(10)
.optional()
.describe("Business categories to filter by (e.g. 'pizza_restaurant')."),
minRating: z
.number()
.min(1)
.max(5)
.optional()
.describe("Only return businesses rated at least this (1-5)."),
minReviews: z
.number()
.int()
.min(0)
.optional()
.describe("Only return businesses with at least this many Google reviews."),
isClaimed: z
.boolean()
.optional()
.describe(
"Filter by whether the listing is claimed by its owner. false surfaces unclaimed listings (outreach prospects).",
),
sortBy: z
.enum(["relevance", "rating", "reviews"])
.optional()
.describe("Sort order for returned rows. Defaults to relevance."),
limit: z
.number()
.int()
@ -223,6 +256,13 @@ const searchLocalBusinessesInputSchema = {
.max(50)
.optional()
.describe("Maximum businesses to return (1-50). Defaults to 20."),
offset: z
.number()
.int()
.min(0)
.max(1000)
.optional()
.describe("Rows to skip for pagination."),
} as const;
const localSearchTypeSchema = z.enum(["maps", "local_finder"]);
@ -241,7 +281,9 @@ const getLocalSerpResultsInputSchema = {
device: z
.enum(["desktop", "mobile"])
.optional()
.describe("Device the SERP is rendered for. Defaults to desktop."),
.describe(
"Device the SERP is rendered for. Defaults to mobile, matching get_local_rank_grid.",
),
depth: z
.number()
.int()
@ -254,13 +296,7 @@ const getLocalSerpResultsInputSchema = {
const getGoogleBusinessQuestionsInputSchema = {
projectId: projectIdSchema,
keyword: z
.string()
.min(1)
.max(200)
.describe(
"Business name or search phrase identifying the Google Business Profile.",
),
...businessIdentifierInputSchema,
near: nearSchema,
depth: z
.number()
@ -379,9 +415,6 @@ type GetGoogleBusinessQuestionsArgs = z.infer<
z.ZodObject<typeof getGoogleBusinessQuestionsInputSchema>
>;
const QUESTIONS_ANSWERS_MIN_RADIUS = 200;
const QUESTIONS_ANSWERS_MAX_RADIUS = 199999;
/**
* Resolves a Labs location + language. Explicit location/language fields win,
* followed by the legacy explicit-US selector; omitted fields inherit the
@ -416,25 +449,11 @@ function resolveMarketSelector(
return resolved;
}
function formatCoordinate(value: number): string {
return Number(value.toFixed(7)).toString();
}
function formatBusinessLocationCoordinate(near: z.infer<typeof nearSchema>) {
return `${formatCoordinate(near.latitude)},${formatCoordinate(near.longitude)},${near.radiusKm}`;
}
function formatQuestionsAnswersCoordinate(near: z.infer<typeof nearSchema>) {
const radius = Math.min(
QUESTIONS_ANSWERS_MAX_RADIUS,
Math.max(QUESTIONS_ANSWERS_MIN_RADIUS, Math.round(near.radiusKm * 1000)),
);
return `${formatCoordinate(near.latitude)},${formatCoordinate(near.longitude)},${radius}`;
}
function formatLocalSerpCoordinate(near: z.infer<typeof localSerpNearSchema>) {
const coordinate = `${formatCoordinate(near.latitude)},${formatCoordinate(near.longitude)}`;
return near.zoom == null ? coordinate : `${coordinate},${near.zoom}z`;
// Business Listings rejects fractional radii ("Invalid Field:
// 'location_coordinate'"), unlike the meter-based business_data radius.
const radiusKm = Math.max(1, Math.round(near.radiusKm));
return `${formatCoordinate(near.latitude)},${formatCoordinate(near.longitude)},${radiusKm}`;
}
function sortOrderByRankedMode(
@ -497,6 +516,33 @@ function buildRankedKeywordFilters(
return filters.length > 0 ? filters : undefined;
}
function buildLocalBusinessFilters(args: {
minRating?: number;
minReviews?: number;
}) {
const filters: unknown[] = [];
if (args.minRating != null) {
pushAnd(filters, ["rating.value", ">=", args.minRating]);
}
if (args.minReviews != null) {
pushAnd(filters, ["rating.votes_count", ">=", args.minReviews]);
}
return filters.length > 0 ? filters : undefined;
}
function localBusinessOrderBy(
sortBy: SearchLocalBusinessesArgs["sortBy"],
): string[] | undefined {
switch (sortBy) {
case "rating":
return ["rating.value,desc"];
case "reviews":
return ["rating.votes_count,desc"];
default:
return undefined;
}
}
function sortCompetitors(
items: Record<string, unknown>[],
sortBy: FindSerpCompetitorsArgs["sortBy"],
@ -598,6 +644,56 @@ const RANKED_KEYWORD_COLUMNS: McpTableColumn<RankedKeywordRow>[] = [
{ header: "url", value: (row) => row.url },
];
// Full Business Listings rows are ~9KB each (popular_times for every day,
// attribute trees, photo URLs) — 10 of them overflow MCP clients' tool-result
// budgets. Return only the fields a candidate list needs; get_business_profile
// serves the full shape for one business.
const LOCAL_BUSINESS_ROW_FIELDS = [
"title",
"description",
"category",
"additional_categories",
"address",
"phone",
"url",
"domain",
"rating",
"is_claimed",
"cid",
"place_id",
"latitude",
"longitude",
"total_photos",
"check_url",
] as const;
// Maps SERP rows likewise ship image CDN URLs, feature ids, and contributor
// links no consumer reads; keep identity, rank, rating, categories, and hours.
const LOCAL_SERP_ROW_FIELDS = [
"rank_group",
"rank_absolute",
"title",
"domain",
"url",
"contact_url",
"address",
"address_info",
"phone",
"category",
"additional_categories",
"rating",
"rating_distribution",
"price_level",
"is_claimed",
"cid",
"place_id",
"latitude",
"longitude",
"total_photos",
"work_hours",
"local_justifications",
] as const;
const LOCAL_BUSINESS_COLUMNS: McpTableColumn<unknown>[] = [
{ header: "title", value: (row) => readPath(row, "title") },
{ header: "category", value: (row) => readPath(row, "category") },
@ -620,6 +716,36 @@ const LOCAL_SERP_COLUMNS: McpTableColumn<unknown>[] = [
{ header: "address", value: (row) => readPath(row, "address") },
];
// Q&A rows carry a ~300-char uule URL plus avatar/contributor links on every
// question AND every nested answer; keep the text, author, and timing.
const BUSINESS_QUESTION_ROW_FIELDS = [
"rank_absolute",
"question_id",
"question_text",
"original_question_text",
"profile_name",
"time_ago",
"timestamp",
] as const;
const BUSINESS_ANSWER_ROW_FIELDS = [
"answer_id",
"answer_text",
"original_answer_text",
"profile_name",
"time_ago",
"timestamp",
] as const;
function trimBusinessQuestionRow(row: unknown): Record<string, unknown> {
const trimmed = pickRowFields(row, BUSINESS_QUESTION_ROW_FIELDS);
const answers = readPath(row, "items");
trimmed.items = Array.isArray(answers)
? answers.map((answer) => pickRowFields(answer, BUSINESS_ANSWER_ROW_FIELDS))
: null;
return trimmed;
}
const BUSINESS_QUESTION_COLUMNS: McpTableColumn<unknown>[] = [
{ header: "question", value: (row) => readPath(row, "question_text") },
{ header: "asked by", value: (row) => readPath(row, "profile_name") },
@ -738,7 +864,7 @@ export const searchLocalBusinessesTool = {
config: {
title: "Search local businesses",
description:
"Searches local business listings near a coordinate. Use this to find local business candidates or nearby competitors; it does not run Maps rank checks or Q&A. Charges credits.",
"Searches local business listings near a coordinate, with optional rating, review-count, and claimed-status filters. Use this to find local business candidates, nearby competitors, or unclaimed listings; it does not run Maps rank checks or Q&A. Returns a compact row per business (identity, contact, rating, claim status); use get_business_profile for one business's full profile. Charges credits.",
inputSchema: searchLocalBusinessesInputSchema,
outputSchema: {
businesses: z.array(looseObjectOutputSchema),
@ -753,12 +879,19 @@ export const searchLocalBusinessesTool = {
handler: withMcpProjectAuth(
async (args: SearchLocalBusinessesArgs, context) => {
const client = createDataforseoClient(context.billing);
const businesses = await client.business.businessListings({
const rows = await client.business.businessListings({
categories: args.categories,
title: args.query,
locationCoordinate: formatBusinessLocationCoordinate(args.near),
isClaimed: args.isClaimed,
filters: buildLocalBusinessFilters(args),
orderBy: localBusinessOrderBy(args.sortBy),
limit: args.limit ?? 20,
offset: args.offset,
});
const businesses = rows.map((row) =>
pickRowFields(row, LOCAL_BUSINESS_ROW_FIELDS),
);
const header = `Found ${businesses.length} local business rows${args.query ? ` for ${args.query}` : ""}.`;
return mcpResponse({
@ -778,7 +911,7 @@ export const getLocalSerpResultsTool = {
config: {
title: "Get local SERP results",
description:
"Fetches one Google Maps or Local Finder SERP near a coordinate. Returns provider rows with rank fields intact; callers decide how to match a target business. Charges credits.",
"Fetches one Google Maps or Local Finder SERP near a coordinate. Returns trimmed provider rows (identity, rank, rating, categories, hours) with rank fields intact; callers decide how to match a target business. Charges credits.",
inputSchema: getLocalSerpResultsInputSchema,
outputSchema: {
results: z.array(looseObjectOutputSchema),
@ -793,15 +926,18 @@ export const getLocalSerpResultsTool = {
handler: withMcpProjectAuth(
async (args: GetLocalSerpResultsArgs, context) => {
const client = createDataforseoClient(context.billing);
const results = await client.serp.local({
const rows = await client.serp.local({
keyword: args.keyword,
locationCoordinate: formatLocalSerpCoordinate(args.near),
languageCode: args.languageCode ?? context.project.languageCode,
searchType: args.searchType ?? "maps",
device: args.device ?? "desktop",
device: args.device ?? "mobile",
depth: args.depth ?? 20,
searchPlaces: false,
});
const results = rows.map((row) =>
pickRowFields(row, LOCAL_SERP_ROW_FIELDS),
);
const header = `Fetched ${results.length} local SERP rows for "${args.keyword}".`;
return mcpResponse({
@ -821,7 +957,7 @@ export const getGoogleBusinessQuestionsTool = {
config: {
title: "Get Google business questions",
description:
"Fetches Google Business Profile questions and answers for one business keyword near a coordinate. Run this only when Q&A evidence is needed. Charges credits.",
"Fetches Google Business Profile questions and answers for one business (by businessName, cid, or placeId) near a coordinate. Run this only when Q&A evidence is needed. Charges credits.",
inputSchema: getGoogleBusinessQuestionsInputSchema,
outputSchema: {
questions: z.array(looseObjectOutputSchema),
@ -835,15 +971,18 @@ export const getGoogleBusinessQuestionsTool = {
},
handler: withMcpProjectAuth(
async (args: GetGoogleBusinessQuestionsArgs, context) => {
const identifier = resolveBusinessIdentifier(args);
const client = createDataforseoClient(context.billing);
const questions = await client.business.questionsAnswers({
keyword: args.keyword,
locationCoordinate: formatQuestionsAnswersCoordinate(args.near),
const rows = await client.business.questionsAnswers({
// The questions endpoint shares the cid:/place_id: keyword prefixes.
keyword: businessIdentifierKeyword(identifier),
locationCoordinate: formatBusinessDataCoordinate(args.near),
languageCode: args.languageCode ?? context.project.languageCode,
depth: args.depth ?? 20,
});
const questions = rows.map(trimBusinessQuestionRow);
const header = `Fetched ${questions.length} Google Business Q&A rows for ${args.keyword}.`;
const header = `Fetched ${questions.length} Google Business Q&A rows for ${businessIdentifierKeyword(identifier)}.`;
return mcpResponse({
text:
questions.length === 0

View File

@ -0,0 +1,149 @@
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import { readPath } from "@/server/mcp/table";
// Input schemas and coordinate formatting for the local-SEO tools. The
// coordinate formatters are also used by the DataForSEO research tools; the
// identifier helpers are used only by local-seo-tools.ts but live here to keep
// that (already max-lines-disabled) module from growing further.
// DataForSEO's Google business_data endpoints take the coordinate radius in
// meters (clamped to 200-199,999), while business_listings/search takes
// kilometers. Everything here speaks kilometers and converts at the edge.
const BUSINESS_DATA_MIN_RADIUS_M = 200;
const BUSINESS_DATA_MAX_RADIUS_M = 199999;
const BUSINESS_DATA_DEFAULT_RADIUS_KM = 10;
export function formatCoordinate(value: number): string {
return Number(value.toFixed(7)).toString();
}
export const businessDataNearSchema = z
.object({
latitude: z
.number()
.min(-90)
.max(90)
.describe("Latitude of the search center."),
longitude: z
.number()
.min(-180)
.max(180)
.describe("Longitude of the search center."),
radiusKm: z
.number()
.min(0.2)
.max(199)
.optional()
.describe(
"Search radius around the center, in kilometers (0.2-199). Defaults to 10.",
),
})
.describe(
"Coordinate to search from. Use it when the business name is ambiguous; otherwise locationCode is enough.",
);
/** "lat,lng,radius" with the radius in meters, as Google business_data wants. */
export function formatBusinessDataCoordinate(near: {
latitude: number;
longitude: number;
radiusKm?: number;
}): string {
const radius = Math.min(
BUSINESS_DATA_MAX_RADIUS_M,
Math.max(
BUSINESS_DATA_MIN_RADIUS_M,
Math.round((near.radiusKm ?? BUSINESS_DATA_DEFAULT_RADIUS_KM) * 1000),
),
);
return `${formatCoordinate(near.latitude)},${formatCoordinate(near.longitude)},${radius}`;
}
export const businessIdentifierInputSchema = {
businessName: z
.string()
.min(1)
.max(200)
.optional()
.describe(
"Business name as it appears on Google. Supply exactly one of businessName, cid, or placeId.",
),
cid: z
.string()
.min(1)
.max(64)
.optional()
.describe(
"Google-defined business CID (from get_local_serp_results rows). Most precise identifier.",
),
placeId: z
.string()
.min(1)
.max(256)
.optional()
.describe("Google Maps place_id (from get_local_serp_results rows)."),
} as const;
type ResolvedBusinessIdentifier = {
keyword?: string;
cid?: string;
placeId?: string;
};
/** Validates the exactly-one-identifier rule the business_data endpoints need. */
export function resolveBusinessIdentifier(args: {
businessName?: string;
cid?: string;
placeId?: string;
}): ResolvedBusinessIdentifier {
const supplied = [args.businessName, args.cid, args.placeId].filter(
(value) => value != null,
);
if (supplied.length !== 1) {
throw new AppError(
"VALIDATION_ERROR",
"Provide exactly one business identifier: businessName, cid, or placeId.",
);
}
return { keyword: args.businessName, cid: args.cid, placeId: args.placeId };
}
/**
* my_business_info and my_business_updates accept only `keyword`, which carries
* the other identifiers through DataForSEO's documented `cid:` / `place_id:`
* prefixes.
*/
export function businessIdentifierKeyword(
identifier: ResolvedBusinessIdentifier,
): string {
if (identifier.cid != null) return `cid:${identifier.cid}`;
if (identifier.placeId != null) return `place_id:${identifier.placeId}`;
return identifier.keyword ?? "";
}
/**
* Allowlist projection for provider rows. Full DataForSEO rows carry image
* URLs, xpaths, and tracking blobs that overflow MCP clients' tool-result
* budgets; each tool declares the fields its consumers actually read.
*/
export function pickRowFields(
row: unknown,
fields: readonly string[],
): Record<string, unknown> {
const trimmed: Record<string, unknown> = {};
for (const field of fields) {
const value = readPath(row, field);
if (value !== undefined) trimmed[field] = value;
}
return trimmed;
}
/** "lat,lng" with an optional trailing map zoom, as the Maps SERP wants. */
export function formatLocalSerpCoordinate(near: {
latitude: number;
longitude: number;
zoom?: number;
}): string {
const coordinate = `${formatCoordinate(near.latitude)},${formatCoordinate(near.longitude)}`;
return near.zoom == null ? coordinate : `${coordinate},${near.zoom}z`;
}

View File

@ -0,0 +1,460 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppError } from "@/server/lib/errors";
import {
getBusinessProfileTool,
getBusinessReviewsTool,
getLocalRankGridTool,
listBusinessCategoriesTool,
} from "./local-seo-tools";
import { makeToolContext, textContent } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
createDataforseoClient: vi.fn(),
fetchBusinessDataTaskResult: vi.fn(),
fetchBusinessListingsCategories: vi.fn(),
getProjectForOrganization: vi.fn(),
getCached: vi.fn(),
setCached: vi.fn(),
}));
vi.mock("cloudflare:workers", () => ({ env: {} }));
vi.mock("@/server/lib/dataforseo", () => ({
createDataforseoClient: mocks.createDataforseoClient,
fetchBusinessDataTaskResult: mocks.fetchBusinessDataTaskResult,
fetchBusinessListingsCategories: mocks.fetchBusinessListingsCategories,
}));
vi.mock("@/server/lib/r2-cache", () => ({
buildCacheKey: (prefix: string) => Promise.resolve(`${prefix}:key`),
getCached: mocks.getCached,
setCached: mocks.setCached,
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization,
},
}));
const toolContext = makeToolContext();
const byText = (a: string, b: string) => a.localeCompare(b);
beforeEach(() => {
mocks.getProjectForOrganization.mockResolvedValue({
id: "project_1",
locationCode: 2840,
languageCode: "en",
});
mocks.getCached.mockResolvedValue(null);
});
describe("get_business_profile", () => {
it("rejects anything other than exactly one business identifier", async () => {
await expect(
getBusinessProfileTool.handler(
{ projectId: "project_1", businessName: "Acme Cafe", cid: "123" },
toolContext,
),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
await expect(
getBusinessProfileTool.handler({ projectId: "project_1" }, toolContext),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
});
it("passes a cid as a prefixed keyword with a metre-radius coordinate", async () => {
const myBusinessInfo = vi.fn().mockResolvedValue({
title: "Acme Cafe",
category: "Coffee shop",
rating: { value: 4.6, votes_count: 210 },
is_claimed: true,
});
mocks.createDataforseoClient.mockReturnValue({
business: { myBusinessInfo },
});
const result = await getBusinessProfileTool.handler(
{
projectId: "project_1",
cid: "123",
near: { latitude: 33.123456789, longitude: -84.987654321, radiusKm: 5 },
},
toolContext,
);
expect(myBusinessInfo).toHaveBeenCalledWith({
keyword: "cid:123",
locationCoordinate: "33.1234568,-84.9876543,5000",
locationCode: undefined,
languageCode: "en",
});
const out = textContent(result);
expect(out).toContain("- title: Acme Cafe");
expect(out).toContain("- rating: 4.60 from 210 reviews");
expect(out).toContain("- claimed: yes");
});
it("falls back to the project market when no coordinate is given", async () => {
const myBusinessInfo = vi.fn().mockResolvedValue(null);
mocks.createDataforseoClient.mockReturnValue({
business: { myBusinessInfo },
});
const result = await getBusinessProfileTool.handler(
{ projectId: "project_1", businessName: "Acme Cafe" },
toolContext,
);
expect(myBusinessInfo).toHaveBeenCalledWith({
keyword: "Acme Cafe",
locationCoordinate: undefined,
locationCode: 2840,
languageCode: "en",
});
expect(result.structuredContent.profile).toBeNull();
});
});
describe("get_business_reviews", () => {
afterEach(() => {
vi.useRealTimers();
});
it("returns a resumable taskId when the task is still queued", async () => {
vi.useFakeTimers();
const reviewsTaskPost = vi.fn().mockResolvedValue("task-1");
mocks.createDataforseoClient.mockReturnValue({
business: { reviewsTaskPost },
});
mocks.fetchBusinessDataTaskResult.mockResolvedValue({
status: "pending",
result: null,
});
const pending = getBusinessReviewsTool.handler(
{ projectId: "project_1", cid: "123" },
toolContext,
);
await vi.runAllTimersAsync();
const result = await pending;
expect(reviewsTaskPost).toHaveBeenCalledWith(
expect.objectContaining({
cid: "123",
depth: 20,
sortBy: "newest",
includeOtherSources: false,
}),
);
expect(result.structuredContent).toMatchObject({
status: "processing",
taskId: "google:task-1",
});
expect(textContent(result)).toContain('taskId "google:task-1"');
});
it("keeps the taskId recoverable when collection fails after a paid post", async () => {
const reviewsTaskPost = vi.fn().mockResolvedValue("task-1");
mocks.createDataforseoClient.mockReturnValue({
business: { reviewsTaskPost },
});
mocks.fetchBusinessDataTaskResult.mockRejectedValue(
new AppError("UPSTREAM_UNAVAILABLE", "DataForSEO HTTP 502"),
);
const failing = getBusinessReviewsTool.handler(
{ projectId: "project_1", cid: "123" },
toolContext,
);
await expect(failing).rejects.toMatchObject({
code: "UPSTREAM_UNAVAILABLE",
});
await expect(failing).rejects.toThrow('taskId "google:task-1"');
});
it("resumes from a taskId without posting a new task", async () => {
const reviewsTaskPost = vi.fn();
mocks.createDataforseoClient.mockReturnValue({
business: { reviewsTaskPost },
});
mocks.fetchBusinessDataTaskResult.mockResolvedValue({
status: "completed",
result: {
reviews_count: 2,
items: [
{
rank_absolute: 1,
time_ago: "a month ago",
rating: { value: 5 },
profile_name: "Cam P.",
review_text: "Rare bottles and great staff.",
owner_answer: "Thanks!",
},
],
},
});
const result = await getBusinessReviewsTool.handler(
{ projectId: "project_1", taskId: "extended:task-9" },
toolContext,
);
expect(reviewsTaskPost).not.toHaveBeenCalled();
// The prefix picks the endpoint, so a resume never needs the original args.
expect(mocks.fetchBusinessDataTaskResult).toHaveBeenCalledWith({
endpoint: "extended_reviews",
taskId: "task-9",
});
expect(result.structuredContent).toMatchObject({
status: "completed",
taskId: "extended:task-9",
});
const out = textContent(result);
expect(out).toContain("# | when | rating | author | source | review");
expect(out).toContain("Rare bottles and great staff.");
expect(out).toContain("| yes");
});
it("rejects a taskId that did not come from this tool", async () => {
await expect(
getBusinessReviewsTool.handler(
{ projectId: "project_1", taskId: "task-9" },
toolContext,
),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
});
});
describe("get_local_rank_grid", () => {
const gridItems = (items: unknown[]) =>
vi
.fn<(input: { locationCoordinate: string }) => Promise<unknown[]>>()
.mockResolvedValue(items);
it("searches a 3x3 grid of coordinates around the center", async () => {
const local = gridItems([]);
mocks.createDataforseoClient.mockReturnValue({ serp: { local } });
await getLocalRankGridTool.handler(
{
projectId: "project_1",
keyword: "coffee",
target: { cid: "123" },
center: { latitude: 40, longitude: -74 },
spacingKm: 2,
},
toolContext,
);
// 2 km spacing at latitude 40: 0.0180874 deg of latitude, 0.0234532 deg of
// longitude. Row 0 is the northern edge. Every point carries a zoom derived
// from the spacing (13z here) so each point's viewport spans its neighbours
// instead of hiding businesses one grid step east or west.
expect(
local.mock.calls
.map(([input]) => input.locationCoordinate)
.toSorted(byText),
).toEqual(
[
"40.0180874,-74.0234532,13z",
"40.0180874,-74,13z",
"40.0180874,-73.9765468,13z",
"40,-74.0234532,13z",
"40,-74,13z",
"40,-73.9765468,13z",
"39.9819126,-74.0234532,13z",
"39.9819126,-74,13z",
"39.9819126,-73.9765468,13z",
].toSorted(byText),
);
expect(local).toHaveBeenCalledWith(
expect.objectContaining({
searchType: "maps",
device: "mobile",
depth: 20,
}),
);
});
it("ranks by exact cid match and summarizes coverage", async () => {
const local = gridItems([
{ rank_absolute: 1, title: "Other Cafe", cid: "999" },
{ rank_absolute: 2, title: "Acme Cafe", cid: "123", place_id: "p1" },
]);
mocks.createDataforseoClient.mockReturnValue({ serp: { local } });
const result = await getLocalRankGridTool.handler(
{
projectId: "project_1",
keyword: "coffee",
target: { cid: "123" },
center: { latitude: 40, longitude: -74 },
},
toolContext,
);
expect(result.structuredContent).toMatchObject({
summary: {
pointsSearched: 9,
pointsFound: 9,
averageRank: 2,
top3Count: 9,
},
matchedBusiness: { title: "Acme Cafe", cid: "123", placeId: "p1" },
});
expect(textContent(result)).toContain(" 2 2 2");
});
it("records each point's result count and top business so nulls are interpretable", async () => {
const local = gridItems([
{ rank_absolute: 1, title: "Other Cafe", cid: "999" },
]);
mocks.createDataforseoClient.mockReturnValue({ serp: { local } });
const result = await getLocalRankGridTool.handler(
{
projectId: "project_1",
keyword: "coffee",
target: { cid: "123" },
center: { latitude: 40, longitude: -74 },
},
toolContext,
);
// The target is absent, but the point still says how contested it was.
expect(result.structuredContent.grid[0]).toMatchObject({
rank: null,
resultsCount: 1,
topResult: { title: "Other Cafe", cid: "999" },
});
});
it("aborts the grid on a credits failure instead of billing every point", async () => {
const local = vi
.fn()
.mockRejectedValue(new AppError("INSUFFICIENT_CREDITS", "No credits"));
mocks.createDataforseoClient.mockReturnValue({ serp: { local } });
await expect(
getLocalRankGridTool.handler(
{
projectId: "project_1",
keyword: "coffee",
target: { cid: "123" },
center: { latitude: 40, longitude: -74 },
},
toolContext,
),
).rejects.toMatchObject({ code: "INSUFFICIENT_CREDITS" });
// Only the first batch may have dispatched; later batches must not bill.
expect(local.mock.calls.length).toBeLessThanOrEqual(3);
});
it("falls back to a case-insensitive title match", async () => {
const local = gridItems([
{ rank_absolute: 4, title: "ACME Cafe Downtown" },
]);
mocks.createDataforseoClient.mockReturnValue({ serp: { local } });
const result = await getLocalRankGridTool.handler(
{
projectId: "project_1",
keyword: "coffee",
target: { name: "acme cafe" },
center: { latitude: 40, longitude: -74 },
},
toolContext,
);
expect(result.structuredContent.summary).toMatchObject({
pointsFound: 9,
averageRank: 4,
});
});
it("keeps the grid when a single point fails", async () => {
let call = 0;
const local = vi.fn().mockImplementation(() => {
call += 1;
return call === 1
? Promise.reject(new Error("upstream blew up"))
: Promise.resolve([{ rank_absolute: 3, cid: "123" }]);
});
mocks.createDataforseoClient.mockReturnValue({ serp: { local } });
const result = await getLocalRankGridTool.handler(
{
projectId: "project_1",
keyword: "coffee",
target: { cid: "123" },
center: { latitude: 40, longitude: -74 },
},
toolContext,
);
expect(result.structuredContent.summary).toMatchObject({
pointsSearched: 9,
pointsFound: 8,
});
expect(textContent(result)).toContain("x");
});
it("surfaces the upstream error when every point fails", async () => {
const local = vi.fn().mockRejectedValue(new Error("upstream blew up"));
mocks.createDataforseoClient.mockReturnValue({ serp: { local } });
await expect(
getLocalRankGridTool.handler(
{
projectId: "project_1",
keyword: "coffee",
target: { cid: "123" },
center: { latitude: 40, longitude: -74 },
},
toolContext,
),
).rejects.toThrow("upstream blew up");
});
});
describe("list_business_categories", () => {
const categories = [
{ category: "pizza_restaurant", businessCount: 120 },
{ category: "plumber", businessCount: 90 },
];
it("caches the full list and filters it in memory", async () => {
mocks.fetchBusinessListingsCategories.mockResolvedValue({
data: categories,
billing: { path: [], costUsd: 0 },
});
const result = await listBusinessCategoriesTool.handler(
{ projectId: "project_1", query: "PIZZA" },
toolContext,
);
// Free endpoint: must never touch the metered client (a zero-credit org
// would otherwise be refused by the credit gate).
expect(mocks.createDataforseoClient).not.toHaveBeenCalled();
expect(mocks.setCached).toHaveBeenCalledTimes(1);
expect(result.structuredContent.categories).toEqual([
{ category: "pizza_restaurant", businessCount: 120 },
]);
expect(textContent(result)).toContain("pizza_restaurant | 120");
});
it("serves a cache hit without calling the provider", async () => {
mocks.getCached.mockResolvedValue(categories);
const result = await listBusinessCategoriesTool.handler(
{ projectId: "project_1" },
toolContext,
);
expect(mocks.fetchBusinessListingsCategories).not.toHaveBeenCalled();
expect(mocks.setCached).not.toHaveBeenCalled();
expect(result.structuredContent.categories).toEqual(categories);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppError } from "@/server/lib/errors";
import { objectSchema } from "@/server/mcp/output-schemas";
import * as researchTools from "./dataforseo-research-tools";
import * as localSeoTools from "./local-seo-tools";
import { getBacklinksProfileTool } from "./get-backlinks-profile";
import { makeToolContext } from "./tool-test-support";
@ -88,10 +89,12 @@ describe("DataForSEO research tool output schemas", () => {
["search_local_businesses", "businesses"],
["get_google_business_questions", "questions"],
["get_ranked_keywords", "keywords"],
["get_business_reviews", "reviews"],
["get_business_updates", "updates"],
])(
"%s accepts typed (non-plain-object) provider rows",
async (toolName, field) => {
const tools = researchTools;
const tools = { ...researchTools, ...localSeoTools };
const tool = Object.values(tools).find((t) => t.name === toolName);
if (!tool) throw new Error(`tool ${toolName} not found`);
@ -103,12 +106,27 @@ describe("DataForSEO research tool output schemas", () => {
const result = await schema.safeParseAsync({
[field]: [new ProviderRow("example.com", 1)],
totalCount: 1,
// Required by the queued business-data tools; ignored by the rest.
status: "completed",
taskId: "google:task-1",
});
expect(result.success).toBe(true);
},
);
it("get_business_profile accepts a typed provider profile object", async () => {
const schema = objectSchema(
localSeoTools.getBusinessProfileTool.config.outputSchema,
);
const result = await schema.safeParseAsync({
profile: new ProviderRow("example.com", 1),
});
expect(result.success).toBe(true);
});
it("get_backlinks_profile accepts a paginated backlinks profile payload", async () => {
const schema = objectSchema(getBacklinksProfileTool.config.outputSchema);

View File

@ -4,6 +4,7 @@ import { getBacklinksOverviewTool } from "./get-backlinks-overview";
import { getBacklinksProfileTool } from "./get-backlinks-profile";
import { getDomainKeywordSuggestionsTool } from "./get-domain-keyword-suggestions";
import { getRankTrackerTool } from "./get-rank-tracker";
import { getBusinessUpdatesTool } from "./local-seo-tools";
import { getSerpResultsTool } from "./get-serp-results";
import { researchKeywordsTool } from "./research-keywords";
import { makeToolContext, textContent } from "./tool-test-support";
@ -17,6 +18,7 @@ import type * as backlinksTargetModule from "@/server/lib/dataforseoBacklinksTar
const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(),
createDataforseoClient: vi.fn(),
fetchBusinessDataTaskResult: vi.fn(),
research: vi.fn(),
profileOverview: vi.fn(),
profileReferringDomainsPage: vi.fn(),
@ -38,6 +40,7 @@ vi.mock("@/server/lib/dataforseo", async () => {
);
return {
createDataforseoClient: mocks.createDataforseoClient,
fetchBusinessDataTaskResult: mocks.fetchBusinessDataTaskResult,
normalizeBacklinksTarget: targets.normalizeBacklinksTarget,
};
});
@ -329,6 +332,37 @@ describe("MCP tool text output (service-backed tools)", () => {
);
});
it("get_business_updates renders each collected post as a text table", async () => {
const updatesTaskPost = vi.fn().mockResolvedValue("task-1");
mocks.createDataforseoClient.mockReturnValue({
business: { updatesTaskPost },
});
mocks.fetchBusinessDataTaskResult.mockResolvedValue({
status: "completed",
result: {
items: [
{
rank_absolute: 1,
post_date: "04/02/2020 00:00:00",
post_text: "We are open for takeaway.",
url: "https://search.google.com/local/posts?q=acme",
},
],
},
});
const result = await getBusinessUpdatesTool.handler(
{ projectId: "project_1", cid: "123" },
toolContext,
);
const out = textContent(result);
expect(out).toContain("# | posted | post | url");
expect(out).toContain(
"1 | 04/02/2020 00:00:00 | We are open for takeaway. | https://search.google.com/local/posts?q=acme",
);
});
it("get_serp_results renders each query's items as a text table", async () => {
const live = vi.fn().mockResolvedValue([
{

View File

@ -113,7 +113,12 @@ OpenSEO MCP exposes tools for SEO research workflows:
- Fetch live Google organic SERP results for keywords.
- Find exact keyword, page, rank, volume, CPC, intent, and traffic rows for a domain or page.
- Compare SERP competitors across a supplied keyword set.
- Search local businesses near a coordinate, fetch one Maps or Local Finder SERP, and read Google Business Q&A when needed.
- Search local businesses near a coordinate, filtering by rating, review count, or claimed status.
- Fetch one Maps or Local Finder SERP, and read Google Business Q&A when needed.
- Audit a Google Business Profile: categories, rating, hours, photos, and claim status.
- Collect Google reviews (including reviews from other sites) and Google Business posts.
- Look up valid Google Business category slugs.
- Check Google Maps rank at each point of a grid around a business.
- Hydrate keywords with search volume, difficulty, intent, CPC, and trends.
- List saved keywords from an OpenSEO project.
- Save useful keywords back to OpenSEO.

View File

@ -31,6 +31,7 @@ MCP connects your agent to OpenSEO data. Skills tell your agent which SEO workfl
- [Keyword Clustering](/docs/skills/keyword-clustering): turn keyword lists into page groups, content priorities, and cannibalization checks.
- [Competitive Landscape](/docs/skills/competitive-landscape): map who is winning across a market and where your openings are.
- [Competitor Analysis](/docs/skills/competitor-analysis): analyze one competitor and turn the research into strategic takeaways.
- [Local SEO](/docs/skills/local-seo): audit a Google Business Profile, compare it to local competitors, and map Maps visibility around a location.
## Promotion workflows

View File

@ -0,0 +1,38 @@
---
title: "Local SEO Agent Skill"
description: "Audit a Google Business Profile, compare it to local competitors, and map Google Maps visibility around a location with your AI agent."
---
<RunSkillCallout command="/local-seo" />
The Local SEO Agent Skill works out why a business does or does not show up in Google Maps and the local pack near its customers, and what to fix first.
Your agent audits the Google Business Profile, compares it to the competitors that actually outrank it nearby, reads the review gap, and runs a rank grid to show where Maps visibility drops off around the location.
You get a profile snapshot, a competitor comparison, a visibility map, and the one fix to do this week.
## What this skill helps your agent do
- Audit a Google Business Profile: categories, rating, hours, photos, and claim status.
- Pull the local competitors that rank nearby and compare profiles head-to-head.
- Analyze the review gap: volume, recency, ratings, and owner replies.
- Map Maps rankings across a grid of points around the business.
- Check Q&A and posting activity when profile basics are already competitive.
- Turn the evidence into a prioritized fix list.
## When to use it
Use this skill when rankings depend on a physical location or service area — storefronts, restaurants, clinics, trades, and any business that lives or dies by the local pack.
It helps when a business ranks at its storefront but not across its service area, when a competitor keeps taking the map pack, or when you need to know whether the gap is categories, reviews, or engagement.
## What you get back
Your agent should return a profile snapshot, where visibility drops off on the grid, a signal-by-signal comparison against the best competitor, and a prioritized action list starting with the one fix for this week.
## How to get the best result
- Give the agent the business name, or better, its `cid` or `placeId`.
- Share the storefront coordinate — a grid centered on the wrong place is worse than no grid.
- Use one to three keywords customers actually search, not the brand name.
- Ask for the cost before running grids larger than 3x3; every grid point is a paid search.

View File

@ -10,6 +10,7 @@
"[Keyword Clustering](/docs/skills/keyword-clustering)",
"[Competitive Landscape](/docs/skills/competitive-landscape)",
"[Competitor Analysis](/docs/skills/competitor-analysis)",
"[Local SEO](/docs/skills/local-seo)",
"[Link Prospecting](/docs/skills/link-prospecting)"
]
}