name: peec-ai-mcp
description: Companion skill for the Peec AI MCP server (https://api.peec.ai/mcp). Load this skill whenever the user mentions Peec, Peec AI, peec.ai, the Peec MCP server, AI visibility monitoring via Peec, or asks the agent to pull visibility / brand / domain / URL / chat data from Peec. Also trigger on mentions of peec_weekly_pulse, peec_competitor_radar, peec_engine_scorecard, peec_topic_heatmap, peec_prompt_grader, peec_source_authority, peec_campaign_tracker, or any of the Peec MCP tool names (list_brands, get_brand_report, get_domain_report, get_url_report, get_chat, get_url_content, get_actions, etc.). This skill teaches agents the real behaviour of the Peec MCP — including gotchas that the official docs either omit or get wrong.
version: 1.0.0
license: CC-BY-4.0
origin: https://github.com/rebelytics/peec-ai-mcp
maintainer: Eoghan Henn / rebelytics ([email protected])
Peec AI MCP Companion Skill
Open-source guidance for AI agents working with the Peec AI MCP server. Agent-agnostic, project-agnostic, CC BY 4.0. Reshare and adapt freely; keep the attribution line.
Living document. If you discover behaviour that contradicts this file — or a new Peec feature that isn't covered — open an issue or PR at github.com/rebelytics/peec-ai-mcp. See
CONTRIBUTING.mdfor the workflow.Behaviour observations here are current as of April 2026. Peec iterates; things drift.
1. What Peec AI MCP is (for the agent)
Peec AI (peec.ai) monitors how brands appear across AI search engines (ChatGPT, AI Overviews, Perplexity, Gemini, Grok, Copilot, Claude, etc.) by running tracked prompts daily and analysing the responses. The MCP server exposes that data — and the ability to mutate the underlying configuration — to any MCP-capable agent.
Server URL: https://api.peec.ai/mcp
Transport: Streamable HTTP
Auth: OAuth 2.0 (browser consent, token persists)
The surface is larger than the official docs suggest: 27 tools (15 read-only, 8 write, 4 destructive), plus 7 slash-command "prompts" that bundle pre-canned analyses. The official /mcp/tools docs page lists only 13 read tools and omits the write/destructive surface entirely (see §7.11 and §7.25).
Glossary of core terms
Terms used throughout this skill. Each carries a specific, non-obvious meaning in Peec's data model.
- Visibility — how often a brand appears in AI responses.
mentions ÷ total tracked chats, returned as a 0–1 ratio (UI shows 0–100). See §7.3 / §7.37. - Share of Voice (SoV) — a brand's share of all mentions across the tracked brand roster. 0–1 ratio (UI shows 0–100). "Tracked" is load-bearing — see position.
- Sentiment — 0–100 score, neutral at 50. Formula:
50 + (sentiment_sum / sentiment_count) × 50. See §7.3. - Position — average rank among tracked brands, not overall. Lower = better. See §7.4.
- Own brand — the
is_own=truerow inlist_brands. Every other row is a tracked competitor. - Mention — the brand name appears in the AI response text, with or without a web fetch.
- Retrieval — the AI engine fetched one or more URLs while answering. Counted in
get_domain_reportandget_url_report. - Citation — a retrieved URL the model explicitly references in its response. All citations are retrievals; not all retrievals become citations.
- Parametric memory — the model answered from training data without fetching web sources.
sources: []on a chat payload is the tell. - Fanout — when an AI engine rewrites the user's prompt into sub-queries before searching. Exposed via
list_search_queries(chat_id=...). - Scraper variant — a model ID suffixed
-scraper(e.g.chatgpt-scraper). Replays prompts through the real consumer app; captures what users see, not raw API output. Prefer for visibility monitoring. See §7.2. - UGC — user-generated content. One of 8 values of
domain_report.classification, covering Reddit, YouTube, forums, etc. See §7.30. - Idempotent — safe to run twice. Peec's soft-deletes are not idempotent — the second call returns "not found". See §7.34.
- Soft-delete — removes the entity from
list_*and filters but preserves it server-side. The only form Peec exposes. See §7.34. - ID prefixes —
or_(project),kw_(brand — legacy name from Peec's keyword-tracking origins),pr_(prompt),tp_(topic),tg_(tag),ch_(chat). Opaque namespace markers, not semantic types (kw_is a brand, not a keyword).
1.1 Data model — brands, prompts, topics, tags are orthogonal
The single most misread part of Peec's surface. Prompts do not "belong to" brands — no brand_id foreign key exists, and update_prompt cannot move a prompt between brands.
Entities:
- Project (
or_…) — root container; everything else hangs off a project. - Brand (
kw_…) — a detection pattern applied against AI response text. Fields:name,aliases,regex,domains,is_own. No link to prompts, topics, or tags. - Topic (
tp_…) — folder-like grouping for prompts. A prompt has zero or one topic. - Tag (
tg_…) — label attached to prompts. A prompt carries zero or more tags. - Prompt (
pr_…) — a question tracked daily. Fields:text,country_code,topic_id,tag_ids. Nobrand_idfield. - Chat (
ch_…) — one AI-engine response to one prompt on one day. Chat text is scanned against every brand's detection pattern, producing mention counts and brand reports.
The brand ↔ prompt relationship is read-only and indirect. Prompts generate chats; chats contain response text; brand detection runs against that text at ingestion. No write path links a prompt to a brand. "Assigning" or "moving" prompts between brands is a category error — brands aren't containers, they're detection patterns.
Practical implications:
update_promptmutates onlytopic_idandtag_ids— nottext, not any notionalbrand_id. See §7.13.update_brandmutates detection patterns (name,aliases,regex,domains). None reference prompts.- For "which prompts mentioned Brand X", run
list_chats(brand_id=…)and inspect theprompt_idcolumn. create_branddefaultsis_own=false— it always creates a competitor. Own-brand election is not exposed via MCP.- The own brand's TLD list matters for classification. If
domains=["example.de"]but the company also operatesexample.com, the domain report classifiesexample.comasCORPORATE, notOWN. See §7.10.
Quick Start — First Report in 3 Minutes
Assumes a Peec AI account (peec.ai) with at least one project, and an MCP-capable client (Claude Desktop used in the example below). Peec publishes official per-client setup instructions at docs.peec.ai/mcp/introduction; follow those and come back here once the connector is live.
- Connect. Settings → Connectors → Add custom connector → URL
https://api.peec.ai/mcp→ Save → click "Connect" on the connector card → authorise in browser. The "Connect" click after saving is easy to miss. - Verify. Ask the agent: "List my Peec AI projects." Expect at least one
or_…project ID in a columnar JSON table (§4). - Pull a first brand report. Ask: "For project
<id>, show my own brand's visibility, mentions, SoV, sentiment, and position over the last 30 days, broken down by AI model." The agent will chainlist_brands(findis_own=true) →get_brand_report(filter on thatbrand_id, dimensionmodel_id, default 30-day window). - Three interpretation rules before you report any numbers:
- Scales are mixed in the same row.
visibilityandshare_of_voicecome back as 0–1 ratios (multiply by 100 for percent).sentimentis already 0–100 (neutral = 50).positionstarts at 1 and lower is better. Don't treat all four as one scale. Details in §7.37. - Position is rank among tracked brands, not overall. A position of 1.6 means "1.6th in the competitor roster you configured", not "2nd overall in ChatGPT's full response". §7.4.
- Mentions include parametric answers. Visibility counts every response where the brand name appears in text, including answers where the engine didn't fetch a single URL. Retrievals and citations count only real web fetches. §7.5.
- Scales are mixed in the same row.
- Empty data ≠ broken tool. Engines returning zeros are usually inactive on your plan (§7.1). Five distinct causes of empty results are catalogued in §7.8 — check that list before concluding "the tool is broken".
That's the minimum useful loop. For the full feature map, keep reading — §8 collects the seven recipes that cover Peec's published primary use cases.
Pre-flight checklist — before you report any numbers
Run through this eight-item check before putting Peec figures in front of a human.
- Metric type identified for every column. Classify each numeric column as Ratio (0–1, multiply by 100), Score (0–100, neutral at 50 for sentiment), Rank (1+, lower is better), Rate (can exceed 1.0), or Count (integer). See §7.37. If you can't name the type, don't display the number.
- Scale normalised to what the human expects. Ratios rendered as percentages (
0.33→33%, not0.33%). Sentiment left as 0–100 with50 = neutralexplicit. Position left as a rank with "lower is better" called out. Rate kept as-is, with anomalies (retrieval_rate=1.8) explained rather than rewritten. See §7.3 / §7.37. - Position read as rank-among-tracked-brands, not overall. Any position figure comes with the "among tracked competitors" qualifier. See §7.4.
- Dimensions and filters validated against the tool schema, not guessed.
get_brand_reportvalid dimensions:prompt_id, model_id, model_channel_id, tag_id, topic_id, date, country_code, chat_id.brand_idis filter-only, never a dimension. See §7.15 / §8.1. - Column names confirmed against the actual response payload. Don't assume a column exists because a recipe says to sort on it. Default undimensioned
get_domain_reportreturnsretrieved_percentage,retrieval_rate,citation_rate— notretrieval_count. See §7.39 / §8.10. - Inactive engines flagged, not reported as zero visibility. Check
list_models(is_active=true)before claiming a brand is invisible on Perplexity/Claude/Gemini. See §7.1 / §7.8. - Empty results diagnosed against the five-cause list in §7.8 before concluding anything is broken. Soft-deleted brands, inactive engines, parametric answers, plan limits, and filter-mismatch all look the same on the surface.
- Unresolved
kw_…IDs flagged as soft-deleted, not as bugs. See §7.38.
If any item fails, stop and fix before reporting. Partial passes produce partial trust.
2. Setup
Connection parameters (same for every client):
- URL:
https://api.peec.ai/mcp - Transport: Streamable HTTP
- Auth: OAuth 2.0 (browser consent, token persists)
- Scope: read + write (despite official docs claiming read-only — see §7.11)
For per-client install instructions, follow Peec's official setup docs at docs.peec.ai/mcp/introduction. Peec maintains the up-to-date list of supported clients and their step-by-step connection flows; this skill deliberately doesn't duplicate that content because it's environment-dependent and gets stale quickly.
Network sandboxing: If you're running inside a sandboxed agent environment (Cowork, certain cloud IDEs, corporate proxies), make sure api.peec.ai is on the outbound allowlist. A blocked request surfaces as a generic connection / 403 error with no in-skill signpost that it's a network-layer issue rather than a Peec bug.
Verify your setup with: "List my Peec AI projects, then show me the tracked brands and topics in the first one." Expected: two sequential tool calls (list_projects → list_brands + list_topics), both returning columnar JSON with at least one row each. If the agent says it has no Peec integration, the connector isn't connected — revisit Peec's official docs and confirm the OAuth consent completed.
Before connecting, make sure the user has a Peec AI account (peec.ai) with at least one project and is logged in in the same browser they'll use for OAuth. OAuth "silently completes" if the browser session is already authenticated — so the absence of a consent screen does not mean the connector is broken.
3. The 27 tools at a glance
Read-only (15)
| Tool | Purpose |
|---|---|
list_projects | All projects accessible to the authenticated user |
list_brands | Tracked brands in a project (own + competitors) |
list_topics | Topics (folder-like groupings of prompts) |
list_prompts | Prompts, filterable by topic_id or tag_id |
list_tags | Cross-cutting labels applied to prompts |
list_models | AI engine catalog (see §7: is_active filter is critical) |
list_chats | Individual AI responses, filterable by brand/prompt/model |
get_chat | Full chat payload (messages, sources, products, brands_mentioned) |
list_search_queries | Sub-queries the AI engine fanned out to |
list_shopping_queries | Shopping-mode queries + product listings |
get_brand_report | Visibility, SoV, sentiment, position aggregates |
get_domain_report | Source-domain retrieval + citation rates |
get_url_report | Source-URL retrieval + citation rates + page classification |
get_url_content | Scraped markdown of any indexed source URL |
get_actions | Opportunity-scored recommendations (broken as of April 2026 — see §7.12) |
Write / mutate (8)
| Tool | Purpose |
|---|---|
create_brand | Track a new competitor (accepts domains, aliases, regex) |
update_brand | Rename / adjust a brand (triggers background recalculation — see §7.19) |
create_topic | Add a topic (optional country_code; name.maxLength=64) |
update_topic | Rename a topic |
create_prompt | Add a tracked prompt (requires country_code; text.maxLength=200) |
update_prompt | Change topic_id / tag_ids (not text — see §7.13) |
create_tag | Add a tag with one of 22 colours (see below) |
update_tag | Rename or recolour a tag |
Tag colour enum (22 values): gray, red, orange, yellow, lime, green, cyan, blue, purple, fuchsia, pink, emerald, amber, violet, indigo, teal, sky, rose, slate, zinc, neutral, stone. The tool's JSON schema is the authoritative source if Peec adds colours later; this list matches the schema as of April 2026.
Destructive (4)
| Tool | Purpose |
|---|---|
delete_brand | Soft-delete brand |
delete_topic | Soft-delete topic (detaches prompts, doesn't cascade) |
delete_prompt | Soft-delete prompt (cascades to chats) |
delete_tag | Soft-delete tag (detaches from prompts) |
All destructive operations are soft-delete — no hard-delete endpoint exposed. Soft-delete is not idempotent from the client's perspective: the second call on an already-deleted ID returns a "not found" error rather than a no-op success (see §7.34).
3.1 Write-operation quick reference
One-table summary of what each entity's update_* tool can actually change, plus whether the write triggers a background recalculation. Useful when planning a tune-up without cross-referencing five different §7 entries.
| Entity | Mutable fields via MCP | Triggers recalc? | Cost | Relevant §§ |
|---|---|---|---|---|
Brand (kw_…) | name, domains, aliases, regex | Yes — on name, regex, aliases changes (409 on concurrent writes) | No plan credits | §7.19, §7.21 |
Topic (tp_…) | name | No | No plan credits | §7.20 |
Tag (tg_…) | name, color | No | No plan credits | §7.35, §7.36 |
Prompt (pr_…) | topic_id, tag_ids (full replace) | No (but affects tag-filtered reports immediately) | create_prompt consumes plan credits — update_prompt does not | §7.13, §7.14, §7.17 |
Immutable fields (can only be changed by delete + recreate): prompt text (§7.13), any is_own assignment (create_brand defaults to false; own-brand election not exposed via MCP), tag and topic country_code once set.
4. Response format
Every read tool returns columnar JSON:
{
"columns": ["id", "name", "visibility", ...],
"rows": [
["kw_…", "Example Brand", 0.14, ...],
...
],
"rowCount": 8,
"total": 8 // optional, present on some report tools
}
Helpers:
- Row is an array of values in column order. Zip to get a dict.
rowCountis the page size, not the grand total. Usetotalwhen present.- Write return shapes:
create_*tools return{id: "…"}— the new entity's ID, nothing else.update_*anddelete_*tools return{success: true}— no echo of the updated record, no diff.- Either way, call
list_*afterwards if you need to verify state.
- Plan credits — only
create_promptcharges. Per the tool's own description, creating a prompt consumes plan credits (prompts are the billable unit in Peec's pricing; every tracked prompt runs daily across the enabled engines).create_brand,create_topic,create_tag, and allupdate_*/delete_*tools do not consume plan credits. Before bulk-creating prompts on a TRIAL or small paid plan, check the remaining credit balance in the Peec UI — there's noget_credit_balanceMCP endpoint. Failedcreate_promptcalls from credit exhaustion return a billing-shaped error rather than a schema error. - Scales are heterogeneous within a single row.
get_brand_reportreturns four headline metrics on three different scales:visibilityandshare_of_voiceas 0–1 ratios,sentimentas 0–100,positionstarting at 1 (lower = better). The columnar JSON envelope gives no hint about this. Read §7.37 before building any report layer.
5. Slash-command prompts (Claude Desktop /, Cursor @)
These are server-side "prompts" in MCP terminology — canned multi-tool analyses with sensible defaults.
| Slash command | What it does | Arguments |
|---|---|---|
/peec_weekly_pulse | Week-over-week digest: brands, competitors, sources, sentiment | project |
/peec_competitor_radar | Flags competitors moving by more than a threshold | project, threshold_pp (default 10) |
/peec_engine_scorecard | Per-engine breakdown of visibility, SoV, sentiment, position | project |
/peec_topic_heatmap | Visibility × topic × engine with severity bands | project |
/peec_prompt_grader | Grades your prompt set on balance, tag hygiene, funnel, duplicates | project |
/peec_source_authority | Domain-as-source audit: retrieval, citation, authority gaps | project |
/peec_campaign_tracker | Before/after comparison around a campaign date | project, campaign_date (YYYY-MM-DD), optional urls |
When the user asks "what's happening this week" or "give me a visibility digest", reach for /peec_weekly_pulse before hand-rolling queries.
Not available in: OpenAI Codex, n8n, most non-Claude/non-Cursor clients. Fall back to composing the equivalent tool calls manually.
6. Hidden features & power moves
6.1 Gap filter on domain/URL reports
The filters param on get_domain_report and get_url_report accepts a special gap operator not shown in the visible schema hints:
{"field": "gap", "operator": "gte", "value": 1}
Returns domains/URLs where competitors appear but the user's own brand doesn't. Essential for competitive content audits.
6.2 mentioned_brand_count filter
{"field": "mentioned_brand_count", "operator": "gte", "value": 2}
Finds sources where multiple tracked brands co-appear — useful for competitive co-citation patterns ("who's being compared to us?").
6.3 regex on create_brand / update_brand
When you're tracking a brand with complex naming variants (e.g. three word variations of the same name), pass a regex alongside aliases:
{"name": "Example", "aliases": ["Example Co", "Ex."], "regex": "\\bEx(?:ample(?:\\s+Co)?|\\.)\\b"}
On update_brand, pass regex: null to clear an existing regex. The base Peec docs at /identifying-your-competitors describe aliases and regex at the brand-UI level but not specifically in the MCP tool context — this skill documents the MCP-side behaviour.
6.4 Two-step get_actions workflow (when fixed)
Always call with scope=overview first — it returns navigation metadata, not recommendations. Then drill down per slice:
scope=owned(no extras)scope=editorial+url_classification(e.g. "LISTICLE")scope=reference+domain(e.g. "wikipedia.org")scope=ugc+domain(e.g. "reddit.com", "youtube.com")
As of April 2026 the tool's MCP schema is empty, so the client strips the scope parameter and the server rejects the call. Workaround in §7.12.
6.5 Wave-based execution for bulk config changes
When a tune-up touches many entities (tags, brands, prompts), execute in three waves rather than one sprint:
- Wave 1 — additive:
create_tag,create_brand. Zero risk; reversible by deletion. Confirm new entities appear in Peec UI before moving on. - Wave 2 — mutation:
update_prompt,update_topic,delete_brand. Changes existing state. Pause to re-check UI. - Wave 3 — creation:
create_prompt. Final bulk add. Resolve any new tag IDs from Wave 1 and substitute into thetag_idsarray before calling.
Between waves, run the appropriate list_* tool to verify state. Use this pattern for any tune-up of more than ~20 operations.
Intra-wave parallelism is safe for independent writes to different entities. Within a single wave, operations that don't reference each other's outputs and don't target the same entity (e.g. 10 independent create_tag calls, or a batch of create_prompt calls whose tag IDs were resolved upstream) can be issued in parallel. Batches of 5–10 parallel MCP calls stay well under the published rate limit of 200 requests/minute per project (see §7.24 and the official /ratelimits docs page). For inter-wave ordering, keep the strict sequence; for intra-wave independent writes to different entities, batch freely. Do not parallelise multiple writes to the same entity — especially update_brand calls against the same brand ID, which triggers a background recalculation and rejects concurrent updates with 409 (see §7.19).
Pre-fetch tag and topic IDs once per wave. When a wave will issue many writes that reference the same set of tag or topic IDs (common in Wave 3 prompt creates), call list_tags and list_topics once at the start of the wave and hold the ID map in-memory for the whole batch. Inter-wave refetches are only required if an intervening wave created new tags/topics.
The companion peec-ai-project-tuneup skill codifies this into a full methodology — load it when the user wants to overhaul a Peec project, not just query it.
6.6 Scraped content via get_url_content
After get_url_report, feed interesting URLs into get_url_content to pull the actual markdown the AI engine was reading. Pass the URL verbatim — trailing slashes and scheme changes break the lookup.
get_url_content has two distinct failure modes that an agent has to dispatch on:
- Failure mode A — URL not indexed by Peec at all: the call returns an error response with the text
"URL not found". This is a hard miss — no retry helps. Skip and continue. - Failure mode B — URL indexed but not yet scraped: the call returns a success envelope with
content: null. Scraping runs up to 24h after first encounter. A retry later in the day often succeeds — queue for a deferred re-run rather than skipping.
An agent looping over get_url_report → get_url_content must branch on these two cases, not conflate them. The error-vs-null distinction is the reliable signal.
7. Data-literacy gotchas (READ THIS BEFORE REPORTING)
These are things the official documentation either omits or gets wrong. Ignoring them produces confident-sounding but materially wrong analysis.
7.1 list_models returns 19 engines; only is_active: true are tracked
On lower-tier plans users select a subset of engines; the others return empty data. On a TRIAL-tier project, is_active: true typically holds for only 3 engines out of 19. Higher tiers unlock more. Filter on is_active: true before building engine breakdowns. Empty results for an inactive model look identical to "no data exists" — there's no error.
7.2 *-scraper models measure consumer behaviour; raw model IDs measure API responses
chatgpt-scraper ≠ gpt-4o. The scraper variants replay queries through the actual consumer app (ChatGPT web UI, Grok web, AI Overview), capturing what users actually see. Raw model IDs (gpt-4o, claude-sonnet-4, grok-4) hit the model API directly — different results, different retrievals, different utility. For visibility monitoring, prefer scraper variants.
7.3 Sentiment formula is not sentiment_sum / sentiment_count
The doc hints that the "raw aggregation fields" let you do custom math. They don't — at least, not naively. Observed relationship:
sentiment = 50 + (sentiment_sum / sentiment_count) × 50
= ((sentiment_sum / sentiment_count) + 1) / 2 × 100 # equivalent form
Where sentiment_sum / sentiment_count is on a -1..+1 scale (neutral = 0 → mid-50). If you compute the ratio directly and report it as a 0–100 sentiment score, you'll be off by a factor of 50 and you'll miss the neutral-centred axis.
Sample verification (four brands, 18-day window):
| Brand | sentiment_sum | sentiment_count | Derived | Displayed |
|---|---|---|---|---|
| Own Brand | 137.609 | 731 | 59.4 | 59 |
| Competitor A | 88.881 | 350 | 62.7 | 63 |
| Competitor B | 54.381 | 221 | 62.3 | 62 |
| Competitor C | 28.246 | 137 | 60.3 | 60 |
All four match the displayed value after rounding. Peec's docs describe sentiment as a 0–100 scale with typical scores in the 65–85 band but do not publish the formula — the formula above is the observed relationship, not an officially sanctioned expression.
Critical caveat: sentiment_count < mention_count. Not every mention carries a sentiment score. In the sample above, the own brand had 819 mentions but only 731 sentiment-scored mentions (88 mentions without a score). If you divide sentiment_sum by mention_count instead of sentiment_count, the result is wrong. Always use sentiment_count as the denominator.
7.4 position in brand reports = position among tracked brands, not overall position
Example: ChatGPT responds with a list of 7 recommendations — Brand A (1), Brand B (2), Brand C (3), Brand D (4), Own Brand (5), Brand F (6), Brand G (7). If only Own Brand is in list_brands, position = 1. It does not mean Own Brand ranked first overall.
Clients who report "position 1 in ChatGPT" based on this metric will materially misrepresent the data. Always verify with get_chat → inspect the actual assistant response text.
Docs ambiguity note: Peec's public metric docs describe position as "average ranking of the brand in AI responses", which reads as an overall-position metric. The observed MCP behaviour — and the only behaviour consistent with the data the server has access to — is that the ranking is computed across tracked brands only, not all brands named in the response. Treat the docs framing as marketing shorthand and the skill framing as the accurate mechanical behaviour.
7.5 Domain report is a retrieval report, not a mention report
sources: [] in get_chat means the model answered from parametric memory. Brands can be mentioned in the response text without any source retrieval. So:
get_brand_reportcounts text mentions (parametric or retrieved).get_domain_reportcounts retrievals (only when the model actually fetched URLs).
High-mention-count brands may have near-zero domain-report presence if the AI engine answers from memory — especially for well-known brands.
Sources vs citations distinction (from Peec's own docs): "sources" are every URL an AI model accesses while answering a prompt; "citations" are the subset of sources that the model explicitly references in the final response text. Peec's reports separate retrieval count from citation count for every domain/URL — don't conflate the two.
7.6 Observed chat counts exceed prompts × active_models × days
Peec's /understanding-chats docs describe the cadence as "daily" and /setting-up-your-prompts adds that accepted prompts "start running immediately, joining the regular 24-hour cycle" — i.e. one run per prompt × model per day. In practice, observed chat counts for a project exceed prompts × active_models × days by a material factor.
Likely explanations — not conclusively verified:
- Model channels multiply the count. Each model (e.g.
gpt-4o) can have multiple channels (openai-0,openai-1, etc.) representing different regional/setting variants.list_chatsfilters bymodel_id, but each model may emit several chats per day from different channels. - Back-fill on acceptance. The "start running immediately" language suggests newly accepted prompts may be run multiple times shortly after acceptance to populate initial data.
- Error retries. Failed prompt runs may re-run on the same day without being deduped in the chat count.
Practical guidance: don't reconstruct "how many chats are expected" from simple arithmetic — it won't match. Use list_chats directly to get the observed count.
7.7 visibility_total varies per dimension cell
In a multi-dimension breakdown (e.g. model × topic), visibility_total differs per cell. Google AI Overview in particular only triggers for some queries, so its denominator is smaller than ChatGPT's in the same topic. If you compute share-of-voice by summing across cells, normalise carefully.
7.8 Empty results have five possible causes
An empty rows: [] response means one of:
- No data actually exists for the filter.
- The filter contains a typo or stale ID (non-existent
prompt_id,brand_id, etc.). - The filter targets an inactive model on the user's plan.
- The date range is before the platform had data or in the future. Report tools with a
start_datethat's far future or far past (e.g.2030-01-01,2015-01-01) return clean empty envelopes, not errors. - The filter targets a soft-deleted entity. Soft-deleted brands, prompts, topics, and tags still exist in the system but are excluded from filter matches.
list_chats(brand_id=<deleted>)returns empty — identical in shape to "no activity".
Peec returns a clean empty array in all five cases — no error, no warning. Before reporting "no data", validate filter IDs by listing first (which excludes soft-deleted entities, so a missing ID in list_* is itself a signal) and sanity-check the date range against the platform's real coverage window.
7.9 Auto-selected competitors on project creation are often wrong
When a user creates a project, Peec auto-suggests competitors based on algorithmic similarity (brands that co-appear in at least two tracked prompts, per Peec's /identifying-your-competitors docs). In practice these skew toward information sites (forums, publications, wiki-likes) rather than actual commercial competitors. In multiple observed projects, Peec auto-selected content sites (reference databases, community forums, industry publications) while ignoring the actual commercial competition (direct retail rivals, marketplaces, adjacent brands) that the domain report reveals once data accumulates.
Skill recipe: after project creation, run list_brands + get_domain_report side-by-side. Any high-retrieval CORPORATE-classified domain not in list_brands is a candidate competitor. Use create_brand with domains + optional regex to add it.
7.10 classification=COMPETITOR in domain report ≠ commercial competitor
In get_domain_report, the classification column can be COMPETITOR — but this just means "domain of a tracked competitor brand", not "direct commercial rival". Don't use it to identify competitive threats; use mention overlap + domain retrieval volume instead.
Multi-TLD brands and the OWN classification. classification=OWN is driven by the domains array on the is_own=true brand row in list_brands, not by name matching or corporate-ownership inference. A brand with multiple TLDs (e.g. example.de, example.com, example.nl) will show OWN only for the specific TLDs listed in that array — all other TLDs of the same commercial entity fall back to CORPORATE (or whatever other classification applies). This is a project configuration completeness issue, not a classification bug. Agents auditing a domain report should check list_brands(is_own=true).domains first; if the array doesn't cover every TLD of the own brand, advise the user to update it via update_brand before drawing conclusions about "own vs. competitor" domain share.
7.11 Official docs say the MCP is read-only. It is not.
The docs state: "All tools are read-only. The MCP server cannot modify your projects, prompts, or settings." This is incorrect as of April 2026 — the server exposes 12 write/destructive tools (8 create_*/update_*, 4 delete_*). All are destructive or billable (creating prompts consumes plan credits). Agents should:
- Confirm with the user before any mutation unless the user has already granted explicit batch authorisation for the current plan. If the user says "go ahead and create these 20 prompts and then delete these 5 tags" as a single instruction, treat that as one consent event — don't re-prompt on each individual call. If the plan changes mid-run (new entities, new destructive steps that weren't in the original batch), fall back to per-step confirmation. Rule of thumb: each batch's confirmation covers only the writes explicitly enumerated at batch approval time.
- Prefer
list_*for verification after a write (since writes return either{id: "…"}for creates or{success: true}for updates/deletes — never an echo). - For experimentation, create a dedicated test project in Peec so writes don't pollute production data.
7.12 get_actions MCP schema is empty — prefer §8.8 for reliable results
The tool description documents scope, project_id, start_date, end_date, url_classification, domain as required/optional parameters. The actual declared JSON schema is empty ({properties: {}, type: "object"}). What happens when you call it depends on the MCP client:
- Schema-strict clients (most of them) strip all parameters before sending, so the server rejects with "No matching discriminator: scope". The call fails outright.
- Pass-through clients send the params anyway. The server then accepts some scopes and rejects others:
scope=editorial,scope=reference,scope=ugccommonly return data;scope=ownedis more fragile and often returns 422. Results are inconsistent between clients and across projects.
Either way, the description field is more polished than the tool is reliable. It walks through the two-step workflow, explains the scope=overview / scope=detailed split, lists the classification enums, and gives mapping rules — reading it, you'd reasonably assume the tool is production-ready. The schema and the observed behaviour say otherwise. Trust the schema and the §8.8 fallback, not the description.
Workarounds:
- Preferred: approximate
get_actionslocally — full step-by-step recipe in §8.8. Produces EDITORIAL and OWNED action lists directly fromget_domain_report+get_url_report, with no dependency onget_actionsworking. - Client-dependent fallback: on a pass-through client, try the specific scopes (
editorial,reference,ugc) individually. Expectownedto fail most of the time. Do not rely on this path for reproducible workflows — treat any successful call as a bonus, not a guarantee. - Report the empty schema to
[email protected]so the properties field gets populated.
7.13 update_prompt cannot change prompt text
Only topic_id and tag_ids are mutable. To "edit" a prompt's text, the required workflow is delete + create:
delete_prompt(prompt_id)— soft-deletes the prompt and cascades to its chats (all tracked history for that prompt is removed).create_prompt(text=..., topic_id=..., tag_ids=..., country_code=...)— creates a fresh prompt.
Cost of the reframe: 1–N days of historical visibility data (depending on how long the prompt had been running) are lost. For a low-performing prompt this is usually worth it. For a high-performing prompt, consider whether the reframe actually needs to happen at all.
7.14 update_prompt.tag_ids is full replacement, not append
Passing tag_ids: ["tg_new"] replaces the entire tag set. To add a tag without losing existing ones, fetch the prompt first and merge:
existing = list_prompts(project_id) → find by id → read tag_ids
new_set = list(set(existing + ["tg_new"]))
update_prompt(prompt_id, tag_ids=new_set)
This is especially important when scripting bulk retags — an unmerged call silently strips every tag the prompt previously carried.
7.15 create_prompt has no language field — only country_code
Unlike Trakkr and Sistrix custom prompt tracking, Peec's create_prompt takes only a country_code (two-letter ISO, e.g. DE, GB, US) and the prompt text. Language is inferred from the text itself. Practical implication: when building multi-market prompt sets, you manage language at the level of the prompt text (write the German prompt in German; write the French prompt in French). There's no separate field to set.
The allowed country codes are constrained to a fixed enum of 92 ISO 3166-1 alpha-2 codes as of April 2026, covering most of Europe, the Americas, Middle East/North Africa, and Asia-Pacific. If your target country isn't on the list, the call will fail validation.
7.16 create_prompt.text max length is 200 characters
create_prompt enforces minLength: 1, maxLength: 200 on the text field. Budget your prompt text accordingly — long-form analytical prompts will be rejected. For very long prompts, break them into focused shorter ones.
7.17 update_prompt accepts topic_id: null to detach
Setting topic_id: null (not empty string, not omitted) removes the prompt's topic assignment. Useful when restructuring topics. To move a prompt from one topic to another, pass the new topic_id directly — no detach step needed.
7.18 Pagination: most list_* tools default to 100, but list_projects and list_models don't paginate at all
list_brands, list_prompts, list_tags, list_topics, list_chats, list_search_queries, list_shopping_queries accept limit (default 100, max 10000) and offset (default 0). The 100-item default is a silent truncation trap — a project with more than 100 prompts, brands, tags, or topics will have its state misread if you don't pass an explicit limit or chain offset calls.
list_projects and list_models have no pagination parameters at all. list_projects accepts only include_inactive (boolean); list_models accepts only project_id. These tools return the full set in a single call, so the truncation trap doesn't apply there — but if you're coding a generic paginator, special-case them.
Two safe patterns for full-state reads on paginated tools:
- Explicit large limit —
list_prompts(project_id, limit=10000)for any inventory task. The server caps at 10000 but that's almost always enough for a single project. - Paginate until empty — call with
offset=0, limit=100, thenoffset=100, limit=100, … untilrowCount=0. This is the pattern used during post-tune-up verification to prove no prompts were missed.
Both work; prefer the explicit-limit form for single-call simplicity, and the paginated form when you want an audit trail that explicitly proves completeness.
Boundary behaviour: limit=0 returns a clean empty envelope rather than rejecting; limit=1 works as expected; limit=999999 is silently capped to the server's 10000 maximum without error. limit=-1 and non-integer limit values are rejected client-side by the schema. The silent cap is the one to remember — an agent asking for "everything" by passing a huge number will silently get a truncated result, not an error.
7.19 update_brand triggers background metric recalculation
Changes to a brand's name, regex, or aliases cause Peec to reprocess all historical chats against the new matcher. The /api-reference/project/update-brand docs confirm: "Changes to name, regex, or aliases trigger a background recalculation of metrics. During recalculation, further updates to these fields are blocked with a 409 Conflict response." Practical consequences:
- If you need to update several aspects of the same brand, combine them into one
update_brandcall rather than sequential calls. - Retries after a 409 during recalculation must wait for the recalc to finish. Back off ~30 seconds and retry.
- Aggregate metrics (visibility, SoV, mention counts) shift retroactively after recalculation completes. Don't compare pre-recalc numbers to post-recalc numbers without flagging the change in your report.
7.20 create_topic has optional country_code and 64-char name limit
create_topic takes project_id and name (required, maxLength: 64) plus an optional country_code from the same 92-country enum used by create_prompt. The country_code on a topic is advisory metadata (it doesn't force child prompts to inherit the country — each prompt still carries its own country_code).
Budget topic names tightly. 64 chars sounds like a lot but multi-word topic names in German, Spanish, or languages with long compound words hit the ceiling fast.
7.21 update_brand.regex accepts null to clear an existing pattern
The schema declares regex as {anyOf: [{type: "string"}, {type: "null"}]}. Passing an empty string ("") is not the same as null — empty string is treated as "match nothing" by the regex engine and will break brand detection. To remove a regex entirely, pass JSON null. To change it, pass the new pattern as a string.
7.22 Deprecated fields to avoid
Peec's API has sunset several fields in 2026. Don't rely on them, and if you find older tooling that does, flag for upgrade:
citation_avg,usage_count,usage_rate— deprecated 2026-03-19 (v0.12.0) per Peec's API changelog; still returning but scheduled for removal. The modern equivalents are retrieval/citation counts split per entity.normalizedUrl— removed (not just deprecated) 2026-03-06 (v0.9.0) from the Get URLs Report endpoint. Any tooling still referencing this field will silently getundefined. Treat the rawurlas canonical.
If a Peec API field appears in a response but isn't documented in the current docs, check the changelog before using it — Peec's deprecation pattern is to leave the field returning for a grace period before removing it.
7.23 HTTP API has features the MCP doesn't expose (yet)
Peec's HTTP API (separate from the MCP server, same platform) exposes functionality that is not callable via the MCP surface as of April 2026. Specifically:
- Prompt and topic suggestions with accept/reject endpoints — Peec can suggest prompts or topics based on project context, but these live in the HTTP API, not the MCP.
- Model channel listing (
list-model-channels) — exposes finer detail about which models/channels a plan has access to.
Why this matters for the agent: do not hallucinate MCP tools for these capabilities. If a user asks for "prompt suggestions", the MCP has no tool for that; either direct them to the Peec UI (where the feature lives) or, if they want programmatic access, to the HTTP API. The MCP surface of 27 tools is the authoritative set for MCP agents.
7.24 Rate limits: 200 requests/minute per project
Peec publishes a rate limit of 200 requests/minute per project (per the /ratelimits docs page). When the limit is hit, the server returns HTTP 429 with three headers:
X-RateLimit-Limit— the ceiling (200)X-RateLimit-Remaining— the remainder in the current windowX-RateLimit-Reset— seconds until the rate limit resets (not a Unix timestamp — check the value is small, not epoch-scale)
Practical implications for agents:
- The limit is 200/min sustained (≈3.3 req/sec averaged over a minute), not a per-second ceiling. Short bursts above 3.3/sec are fine as long as the rolling-minute total stays under 200. The intra-wave parallelism of 5–10 concurrent calls (§6.5) is a burst of 2–3 sec; even large tune-ups spread across several waves with inter-wave verification pauses stay well below 200/min in any rolling window.
- If you do hit a 429, back off using the
X-RateLimit-Resetheader (exponential backoff starting at 2s is more than adequate). Don't retry more aggressively than the header allows. - Per-project rate limits mean multi-project batch workloads can legitimately parallelise across projects — a 10-concurrent call batch across 5 projects is 50 concurrent calls in aggregate without violating the limit, because each project has its own counter.
MCP clients cannot see these headers. The X-RateLimit-* headers live on the HTTP response envelope; MCP tool calls only surface the JSON body to the agent. The header-based backoff guidance above therefore applies to direct HTTP-API consumers (using x-api-key against api.peec.ai), not to MCP agents. MCP-side strategy is "pace by construction":
- Keep intra-wave parallelism to 5–10 concurrent calls with 1–2 second inter-wave pauses. Most time is spent waiting on verification reads, so the rolling-minute total stays well below 200 req/min.
- If an MCP tool call returns an error whose text mentions rate limiting or exhaustion, treat it as a 429-class signal and back off ≥30 seconds before retrying. There's no header to read.
- When building loops, prefer batching with explicit pauses over raw concurrency. A
for wave in waves: dispatch; sleep(2)loop is safer than an unboundedPromise.all.
7.25 Docs /mcp/tools page is incomplete
Peec's own documentation at https://docs.peec.ai/mcp/tools lists only 13 read tools. It omits:
list_search_queriesandlist_shopping_queriesfrom the read surface.- The entire write surface (
create_*,update_*— 8 tools). - The entire destructive surface (
delete_*— 4 tools).
When in doubt, the authoritative tool list is what the MCP server announces on connection (via tools/list), not the docs page. This skill documents the full 27-tool surface.
7.26 Empty collections are encoded inconsistently (null vs [])
Different endpoints encode "no items" differently — and the inconsistency also shows up within a single row of the same endpoint:
list_brandsreturnsaliases: nullon brands with no aliases set, butdomains: [](empty array) on brands with no domains set. Same row, two different encodings.list_promptsreturnstag_ids: []on prompts with no tags attached.- Newly created entities via
create_brand(nodomainsoraliasesspecified) come back withdomains: []andaliases: null— confirming the split isn't a legacy-vs-new data artefact.
An agent that does brand.aliases.length or brand.aliases.map(...) on the null form will crash; .length or iteration works on the [] form. Defensive pattern:
const aliases = brand.aliases ?? [];
const tags = prompt.tag_ids ?? []; // harmless even though tag_ids is already []
This is a server-side inconsistency, not a client bug. When writing code that touches multiple endpoints, always coerce collection fields with ?? [] before iterating.
7.27 create_prompt rejects duplicates with a distinct error
Attempting to create a prompt whose (text, country_code) pair already exists returns:
Error: "This prompt already exists for this location"
This is a distinct 409-class scenario from the update_brand recalculation conflict in §7.19 — different tool, different cause. Agents should:
- Dedupe their input against a
list_prompts(limit=10000)snapshot before bulk-loading. - When this error surfaces mid-wave, treat it as "skip and continue" rather than retry — retrying will always hit the same error.
- Note that the deduplication key is
(text, country_code)— the same prompt text in a different country is a separate prompt and will be accepted.
7.28 list_models display names lag behind upstream model IDs
Peec's model catalog shows occasional mismatches between id and name. Examples observed:
id | name |
|---|---|
gemini-2.5-flash | Gemini 1.5 Flash |
gpt-4o-search | GPT 5 Search |
This is almost certainly a label-sync lag in Peec's internal model catalog. The practical implication for agents: never text-match against name — always filter by id. If you're building a UI summary that displays model names, consider showing the id alongside so the user can spot the discrepancy.
7.29 Chat payload uses camelCase where other endpoints use snake_case
get_chat returns chat payloads whose sources[] entries use camelCase keys:
urlNormalizedcitationCountcitationPosition
Every other endpoint in the MCP surface (list_prompts, get_brand_report, get_domain_report, get_url_report, etc.) uses snake_case. Additionally, the chat payload carries a model_channel object ({id: "xai-0"} etc.) alongside model ({id: "grok-scraper"}). This field matches the model_channel_id filter/dimension but is not otherwise documented.
Do not conflate urlNormalized with the removed normalizedUrl field (§7.22). That one was on the Get URLs Report endpoint and is gone in v0.9.0. urlNormalized lives on the chat sources payload, is currently active, and is not scheduled for deprecation that this skill has seen.
Defensive parsing: when walking chat sources, read both snake_case and camelCase forms and log a warning if only one exists — Peec may normalise one direction or the other in a future release.
7.30 get_domain_report.classification has 8 values, not 5
The skill's earlier references (and most narrative examples) mention five domain classifications: CORPORATE, EDITORIAL, OWN, UGC, COMPETITOR. The live enum is larger. Observed values:
CORPORATE, EDITORIAL, INSTITUTIONAL, UGC, REFERENCE,
COMPETITOR, OWN, OTHER
Concrete examples:
INSTITUTIONAL— university / public-sector domainsREFERENCE— Wikipedia-style reference contentOTHER— fallback for domains that don't fit the other categories
An agent building a segmentation filter who only knows the five "obvious" values will silently exclude a material slice of domains — often including the REFERENCE bucket that matters most for authority-gap work. Always enumerate the full eight-value set when filtering.
7.31 get_url_report.classification has 11 values (10 observed + 1 schema-declared)
Parallel to §7.30, the URL classification enum is also larger than most narrative examples suggest. Observed values:
HOMEPAGE, CATEGORY_PAGE, PRODUCT_PAGE, LISTICLE, COMPARISON,
PROFILE, DISCUSSION, HOW_TO_GUIDE, ARTICLE, OTHER
Client-side schema validation reveals one additional value — ALTERNATIVE — that exists in the schema but rarely appears in live data. Full documented enum is therefore 11 values.
Recipe §8.2 (URL-level gap analysis) depends on filtering by these values; an agent that filters to [LISTICLE, COMPARISON] only will miss gap rows with classifications like ARTICLE or HOW_TO_GUIDE that are equally relevant to AI-search visibility. Pick the slice deliberately from the full enum, don't default to the two obvious values.
7.32 MCP output size limit — large responses auto-save to file
High severity — silent response-shape change.
MCP tool results are subject to a token cap (empirically ~130K characters). When a response exceeds the cap, the Cowork runtime (and equivalent runtimes in other clients) auto-saves the payload to a file and returns a file pointer in place of the data. The call still "succeeds" — but the in-band shape the agent receives is different from a normal columnar-JSON envelope.
Observed triggers:
get_domain_report(limit=10000, 12-month range)— exceeded cap, saved to file.get_url_report(limit=1000, 12-month range)— exceeded cap, saved to file.
Implications for an agent:
- Large responses will not arrive in-band. If your agent parses the first tool result as columnar JSON unconditionally, a file-pointer envelope will look like a parse failure.
- The file-pointer "success" path looks different from an empty-result path. Don't conflate the two.
- Chained recipes can silently fragment across file drops. A
get_domain_report → filter → get_url_reportworkflow that pulls a wide date range at high limit can truncate silently if either call overflows.
Defensive pattern — three layers:
- Cap
limitaround 200–500 for analytical pulls. Only reach for 10000 when you specifically need the full dataset and have verified your date range is narrow enough. - Narrow the date range before widening the limit. A 30-day window at limit=10000 is safer than a 12-month window at limit=1000.
- Recognise the file-pointer envelope shape. If the tool result isn't columnar JSON, check for a file path in the response before treating it as an error or as "no data".
This is MCP runtime behaviour, not Peec-server behaviour. It applies to any tool whose response is large enough to exceed the cap.
7.33 Parameter-fuzz error-layer catalogue — know which layer rejected your call
When a call fails, the error shape tells you which layer rejected it. This matters because retry strategies differ: client-schema errors mean "change the input"; server errors often mean "change the filter"; rate-limit errors mean "back off". Observed patterns:
| Bad input | Rejected by | Error text shape | Retry strategy |
|---|---|---|---|
Bad enum (e.g. classification="FOO") | MCP client-side schema | Full valid-values list echoed | Pick a valid enum value |
Malformed date (not ISO, or invalid calendar date like 2025-02-29) | MCP client schema pattern | Schema-pattern error with the regex | Fix the date; use a calendar-aware date library |
Non-integer or out-of-range limit | MCP client-side schema | Type / minimum error | Fix the type |
Fake project_id | Server (after auth) | "project not found" | Re-check the ID; don't retry |
Fake brand_id / prompt_id / tag_id / topic_id | Server (after auth) | "<entity> with ID <id> not found" | Re-check the ID; don't retry |
| Soft-deleted entity ID (second delete, or filter after delete) | Server | Same "not found" shape as fake ID | Treat as "already done"; see §7.34 |
Date-range order reversed (start_date > end_date) | Server | "start_date must be on or before end_date" | Swap the dates |
limit above server max | Server | Silently capped to 10000 (not an error) | Accept the cap or paginate |
| Rate limit exhaustion (HTTP 429) | Server | Error text mentions "rate limit" / "too many requests" | Back off ≥30s; see §7.24 |
Practical guidance: when an agent encounters an error, branch on whether the error text contains the entity name (server error, fix the ID), a schema keyword (client error, fix the input shape), or rate-limit language (wait and retry). Don't treat all errors as transient and retry indiscriminately — you'll burn rate-limit budget against calls that will never succeed.
7.34 Soft-delete is not idempotent — second delete returns "not found"
The skill documents deletes as soft-delete (§3), which is true. What it doesn't make explicit is that soft-delete is not idempotent from the client's perspective. The first delete_tag(tag_id) or delete_prompt(prompt_id) returns {success: true}; the second call on the same ID returns an error:
"Tag with ID tg_… not found""Prompt with ID pr_… not found"
The shape is identical to what you'd get from a genuinely fake ID (§7.33) — there's no way for the client to distinguish "was deleted" from "never existed".
Why this matters for bulk deletes: a retry loop that treats any error as a transient failure will bounce forever on an already-deleted entity. The correct pattern is to treat "not found" errors on delete_* as "already done; continue", not as a real failure. Keep a client-side record of deletion attempts so you can distinguish between a first-attempt "fake ID" (real error, investigate) and a retry-attempt "not found" (expected, continue).
The same pattern is expected on delete_brand and delete_topic.
7.35 update_* on identical values silently succeeds
update_tag(tag_id=..., name="Same", color="blue") on a tag that already has exactly that name and colour returns {success: true}. No echo of the record, no diff field, no indication that nothing actually changed. The same no-op-returns-success pattern is expected for update_topic, update_prompt, and update_brand.
Why this matters: an agent running a bulk update_prompt(tag_ids=...) across 67 prompts will see {success: true} for every call — including prompts whose existing tag set already matched the target. The write "succeeded" in the sense that the server accepted it; it did not in the sense that there was no actual data change.
Defensive pattern: when doing diff-driven bulk updates, compute the diff on the client against a list_* snapshot before issuing update_* calls. If the diff is empty, skip the call. This also reduces load against the 200 req/min rate limit (§7.24) because no-op writes still count against the budget.
7.36 Input-constraint asymmetry across create_* tools
Not all create_* tools enforce the same classes of constraints, which is a landmine for agents that assume symmetry. Known asymmetries:
| Tool / field | maxLength | Other constraints |
|---|---|---|
create_prompt.text | 200 (hard, client-side schema) | country_code required from 92-country enum (§7.15) |
create_topic.name | 64 (hard, client-side schema) | country_code optional (§7.20) |
create_tag.name | None enforced — 169-char names accepted | No country code; 22-colour enum |
create_brand.name | Not systematically probed | Accepts domains, aliases, regex |
Additionally, the date-field regex is calendar-aware — 2024-02-29 is accepted (leap year) but 2025-02-29 is rejected client-side (non-leap year). An agent computing dates by arithmetic (e.g. "365 days ago") can construct invalid calendar dates in non-leap years and get a schema error that looks like a Peec bug when it's actually correct rejection of a bad input. Always use a date library for date math.
Practical implication: do not build UI/validation logic on the assumption that name fields share a common maxLength, or that any string that "looks like a date" is a valid date. The enforcement surface is heterogeneous and needs per-tool handling.
7.37 Peec report tools return FIVE different metric types — treat each column by its type, not by name
High severity — load-bearing interpretation rule. Previous skill versions described a "three-scale" problem. Fresh testing in April 2026 revealed it's actually five distinct metric types, and the difference between them matters a lot. Getting this wrong produces report numbers that are off by 100× or that make no sense at all.
The five metric types in any Peec report row:
| Type | Wire scale | Display convention | Examples | Rule |
|---|---|---|---|---|
| Ratio | 0–1 float | 0–100% (multiply by 100) | visibility, share_of_voice on get_brand_report; retrieved_share on get_domain_report | These are fractions of a bounded whole. Multiply by 100 for display. |
| Score | 0–100 | 0–100 (no conversion) | sentiment (neutral = 50) | Already on display scale. Do not multiply. |
| Rank | 1+, lower better | 1+ with "lower is better" caption | position on get_brand_report | Never a percentage. Render with an arrow or explicit direction note. |
| Rate | can exceed 1.0 | Averages-per-source, not a capped ratio | retrieval_rate, citation_rate on get_url_report and get_domain_report | Do not multiply by 100 and display as a percentage. A retrieval_rate of 1.8 means "on average, this domain is retrieved 1.8 times per tracked chat it appears in". Displaying as "180%" is nonsense. These are ratios with no upper bound. |
| Count | integer | raw integer | mention_count, retrieval_count, citation_count, chat_count, mentioned_brand_count | Display as-is. |
Example own-brand 30-day get_brand_report row:
visibility=0.32 (Ratio → 32%), share_of_voice=0.24 (Ratio → 24%), sentiment=59 (Score → 59/100, slightly positive), position=1.6 (Rank → 1.6th among tracked brands), mention_count=819 (Count).
The contradiction with Peec's public docs. The brand metric pages under docs.peec.ai/metrics/brand-metrics/ (one page each for visibility, share-of-voice, sentiment, and position) describe visibility and share-of-voice as 0–100 values — that's the UI display convention. The MCP server returns the 0–1 wire format. An agent reading the Peec docs first and then hitting the MCP will systematically report visibility and SoV as ~100× smaller than they actually are (0.32 reported as "0.32%" rather than "32%"). Symmetric mistake on the Rate side: treating retrieval_rate=1.8 as a ratio and reporting "180% retrieval rate" is meaningless but superficially plausible.
Defensive pattern for any report layer:
- Look up each column's type against the table above before applying any display transformation.
- For Ratio columns, multiply by 100; for Rate columns, never multiply.
- Leave Score columns alone.
- Render Rank with direction; never as a percentage.
- When concatenating values into prose, scale explicitly — "visibility of 32% (0.32 on the wire)" is safer than "visibility of 0.32" or "visibility of 32".
- If you encounter a column not in the table above, sample its values across several rows: a column with values consistently between 0 and 1 is a Ratio; a column with values spread from 0 to 100 is likely a Score; a column with values ≥1 that correlate with a count column is likely a Rate or Count.
7.38 Aggregated reports may reference brand IDs that list_brands no longer returns
get_domain_report and get_url_report include a mentioned_brand_ids array (and mentioned_brand_count column) that record every tracked brand that co-appeared in the responses indexed by that domain/URL. This array can contain brand IDs that do not appear in the current list_brands output — typically 1–3 extra IDs.
Likely cause: soft-deleted brands. Peec's soft-delete (§7.34) removes the brand from list_brands filter matches but preserves the historical aggregated data. A brand that was tracked three months ago and then deleted will still appear in any aggregated report whose date range overlaps its active period.
Other possible causes (not disproven in live sampling):
- Brand ID renumbering on
update_brandwithregexchanges (§7.19 triggers recalculation; the brand ID itself is stable, so this is unlikely to be the cause, but worth noting). - Project configuration changes (a brand moved between projects in an older platform state).
Practical guidance: when an agent encounters a mentioned_brand_ids entry that doesn't resolve via list_brands, don't treat it as an error. The two reasonable responses are:
- Ignore silently — fine for summary-level reports where the unresolved brand doesn't materially change the narrative.
- Flag as "formerly tracked" — useful when
mentioned_brand_countis a headline metric and you need the reader to understand that some of those mentions reference brands the roster no longer tracks.
Never retry or treat this as a client-side bug. The data is intentional; the list_brands filter exclusion is by design (§7.8 cause 5).
7.39 get_domain_report and get_url_report return different column types for the same-named metric
Peec's domain-level and URL-level reports look similar but have quietly divergent schemas for their quantitative columns:
| Column | get_domain_report | get_url_report |
|---|---|---|
retrieval_count | float (aggregated retrievals weighted across chats) | integer (raw count of retrievals for this URL) |
citation_count | float | integer |
retrieval_rate | float (Rate, can exceed 1.0 — see §7.37) | float (Rate) |
citation_rate | float | float |
mention_count | integer | integer |
Practical implication. Code that reads from both reports and assumes "retrieval_count is an int" will crash when it hits the domain report (which returns decimals like 12.5 for aggregated retrievals). Conversely, code that expects a decimal from the URL report is harmless but misleading — you'll get integer arithmetic where you expected a ratio.
Additional column-visibility caveat: retrieval_count and citation_count may not appear in the default, undimensioned get_domain_report response — observed runs returned only retrieved_percentage, retrieval_rate, and citation_rate. The count columns surface on dimensioned queries (e.g. dimensions=[model_id]) and on the URL-level report. If you need "top domains by retrieval volume" without a dimension, sort on retrieved_percentage (Ratio) rather than retrieval_count. See §8.10 step 1 for the corrected recipe.
Cause (inferred, not confirmed): the domain report aggregates across URLs at the same domain and weights by chat participation; the URL report reports per-URL discrete retrievals. The aggregation produces floats naturally.
Defensive pattern: parse both reports through a type-coercion layer that explicitly accepts int | float for the _count columns and only treats _rate columns as rates. Don't assume schema symmetry across the two endpoints.
8. Common recipes
The recipes below collectively cover Peec's six published primary use cases (per docs.peec.ai/mcp/use-cases): brand visibility monitoring (§8.1), competitive intelligence (§8.2a / §8.2b / §8.4 / §8.9), source authority audits (§8.2a / §8.10), prompt optimisation (§8.5, plus /peec_prompt_grader), action-driven SEO (§8.2b / §8.8, plus /peec_source_authority), and cross-platform benchmarking (§8.1 with dimensions=[model_id]). Recipe §8.7 (full project tune-up) wraps the atomics into a single overhaul workflow.
Start here for the most common request type. When a user says "give me a visibility report", "full report", "monthly report", or anything comparable, reach for the composite meta-recipe in §8.0 first — it sequences the atomic recipes below into the three common depth tiers (quick / standard / deep).
Composite recipes answer recurring multi-step questions that the atomics only partially cover: §8.8 approximates the broken get_actions tool; §8.9 is the full competitive gap analysis flow (strategic view, not just a URL list); §8.10 is a source-authority audit (who's citing whom, and at what rate); §8.11 is the safe test-entity lifecycle pattern for agent-driven experimentation.
8.0 "Give me a full visibility report" (meta-recipe)
This is the most common user request in practice. Users don't ask for a brand report or a domain report — they ask for a visibility report, full stop. The skill's atomic recipes (§8.1–§8.6) each answer one slice; a "full report" is a deliberate composition of several.
Three depth tiers map to the three common phrasings:
| User phrasing | Depth tier | Recipes to chain | Typical runtime |
|---|---|---|---|
| "quick check", "how are we doing" | Quick | §8.1 only (steps 1–4, skip step 5) | 2–3 calls |
| "visibility report", "full report", "monthly report" | Standard | §8.1 (all steps) + §8.1 with dimensions=[topic_id] + §8.2a + 1 sample chat via §8.3 | 7–10 calls |
| "deep dive", "audit", "everything we have" | Deep | Standard + §8.2b (URL gaps) + §8.6 (shopping queries) + 3 sample chats (one per active engine) | 12–15 calls |
Standard-tier execution order:
1. Follow §8.1 steps 1–5 → own-brand per-engine breakdown + full roster.
2. Repeat §8.1 step 4 with dimensions=[topic_id] → topic-level heat map.
Zero-visibility topics are signal, not noise — they show which content
verticals the brand is invisible on. Include them in the report,
don't filter them out.
3. Follow §8.2a → domain-level source-authority gaps.
4. Pick one chat from the highest-mention-count engine and follow §8.3
→ one piece of qualitative colour (what the AI actually said).
5. Scale-normalise everything (§7.37) before writing the report:
visibility and SoV × 100, sentiment as-is, position flagged as
"rank among tracked brands" (§7.4).
Date-range defaults by tier:
| Tier | Window | Notes |
|---|---|---|
| Quick | 7–30 days | 7 only if chat volume is high enough to smooth. |
| Standard | 30 days | The baseline. Matches most clients' reporting cadence. |
| Deep | 90 days or "since project creation" | Watch §7.32 output-size caps — pair wider ranges with tighter limit values. |
Never pull 12-month at limit=10000 without checking §7.32 — you'll hit the MCP output cap and the payload will silently save to a file instead of returning in-band.
Output framing. A full visibility report should always include: (1) the headline four-metric row for the own brand, (2) the competitive roster comparison, (3) the per-engine breakdown with the is_active=false engines flagged as inactive rather than zero (§7.1), (4) the topic heat map with zero-visibility topics called out as content gaps, (5) the domain-gap list with 3–5 actionable items, (6) one sample-chat quote showing the AI's actual framing. The atomic recipes give you the data; this meta-recipe gives you the narrative order.
8.1 "How visible is my brand this month?"
1. list_projects → pick project_id.
2. list_brands → note brand_id for is_own=true AND the competitor brand_ids.
3. list_models → note model_ids with is_active=true (see §7.1).
4. get_brand_report(project_id,
start_date=<30 days ago>, end_date=<today>,
filters=[{field: brand_id, operator: in, values: [own_id]}],
dimensions=[model_id])
→ own-brand breakdown per AI engine.
(start_date and end_date are REQUIRED on every report tool — there is
no "default to last 30 days" behaviour. Omitting them returns a
schema error, not a default window.)
4b. Optionally, repeat step 4 with dimensions=[topic_id] instead of
[model_id] → topic-level heat map. This reveals which content
verticals the brand dominates vs. where it's invisible. Typically
one of the most actionable dimensions in a standard report —
consider it a near-default step, not an add-on.
5. get_brand_report(project_id,
start_date=<30 days ago>, end_date=<today>)
→ full-project roster, no dimension. This is the competitive
benchmark: where does the own brand rank against each tracked
competitor on visibility, SoV, sentiment, position?
6. Report the own brand's numbers alongside the roster view.
Apply the scale rules (§7.37): multiply visibility and SoV by 100,
keep sentiment as-is, flag position as "rank among tracked brands".
Flag is_active=false engines as "inactive on plan, no data" rather
than "zero visibility".
Why the two-call structure: step 4 answers "how are we doing where we show up?" (per-engine detail for our brand); step 5 answers "how do we compare to competitors?" (full-roster benchmark). Skipping step 5 is the most common mistake — a single own-brand number has no meaning without the competitive frame.
Per-engine head-to-head competitive comparison. The reports above give two useful views separately (own brand per engine, and all brands in one go) but no built-in way to read "how do we compare to competitor X on each engine". brand_id is only valid as a filter, not as a dimension — valid dimensions are prompt_id, model_id, model_channel_id, tag_id, topic_id, date, country_code, chat_id. To get a per-engine head-to-head, make two calls and pivot client-side:
# Call 1 — own brand, per engine
get_brand_report(project_id, start_date=…, end_date=…,
filters=[{field: brand_id, operator: in, values: [own_id]}],
dimensions=[model_id])
# Call 2 — competitor, per engine
get_brand_report(project_id, start_date=…, end_date=…,
filters=[{field: brand_id, operator: in, values: [competitor_id]}],
dimensions=[model_id])
→ two result sets, one row per engine per brand. Zip them by model_id
into a small table (one row per engine, columns for own vs. competitor
on visibility / SoV / sentiment).
This is the single most informative shape for a "we vs them" narrative — better than listing raw per-engine numbers and leaving the reader to do the arithmetic. When you're writing a competitive summary, reach for this pivot before writing any prose.
Earlier drafts of this skill recommended dimensions=[model_id, brand_id] for this pivot. That shape is rejected at the schema layer because brand_id isn't in the dimensions enum. The two-call pattern above replaces it.
Default window: 30 days. Shorter (7 days) is only useful if the project has high chat volume; longer (90 days) smooths signal but hides recent shifts. For anything longer than 30 days, read §7.32 on the output-size cap before pulling. For full-report composition, see §8.0.
8.2a Domain-level competitor analysis — "who's beating us at the source level?"
1. get_domain_report with filters=[{field: gap, operator: gte, value: 1}]
→ domains where competitors appear but we don't.
2. For each high-retrieval COMPETITOR-classified domain (§7.30), inspect
which of our tracked brands are mentioned vs ours. This is the
source-authority slice.
3. Decide: is this a "create content" gap (we should produce on our own
domain) or a "get mentioned" gap (we should earn placement on theirs)?
Use this for source-authority audits and for feeding /peec_source_authority. Note the full domain classification enum is 8 values, not 5 (§7.30) — don't filter only to [CORPORATE, COMPETITOR, OWN] or you'll silently exclude the REFERENCE and INSTITUTIONAL buckets that often matter most.
8.2b URL-level competitive gap analysis — "which specific pages should we target?"
This is a sibling workflow to §8.2a, not a continuation of it. An agent trying to chain the two by filtering domain results down to their own URLs via a second call will often find the competitor domain has no LISTICLE / COMPARISON classifications of its own — the gap signal lives at the URL level across all domains, not at the per-domain level. Run URL-level gap analysis as its own flow:
1. get_url_report with filters=[{field: gap, operator: gte, value: 2}]
limit=20 (keep it bounded; see §7.32 output-size cap)
→ specific URLs where competitors appear multiple times and we don't.
2. Pick the subset with classifications you can meaningfully influence
(LISTICLE, COMPARISON, HOW_TO_GUIDE, ARTICLE — see §7.31 for the full
11-value enum).
3. get_url_content on each → actual markdown the AI engine is reading.
4. Analyse what gets a brand included: positioning, specificity of claims,
structure of the listicle, the questions answered.
Why gap >= 2 and not gap >= 1: gap=1 surfaces pages where a single competitor appears once without the own brand — often just incidental coverage (a passing mention in a long article). gap>=2 filters to pages where multiple competitors co-appear without the own brand, which is a stronger signal of a systematic editorial exclusion worth pursuing. Dial down to gap>=1 for very new or low-data projects; dial up to gap>=3 for mature projects with hundreds of retrievals per domain where the noise threshold is higher. Two is the pragmatic default.
The two flows answer different questions. §8.2a: "where is our source authority weak?" §8.2b: "which specific editorial placements should we pursue?" Most projects need both, run independently.
8.3 "What's the AI engine actually searching for?"
1. list_chats → pick one chat_id
2. list_search_queries(chat_id=...) → see the sub-queries the engine issued
3. get_chat → full response + brands_mentioned + sources
This workflow is what transforms Peec from a dashboard into a research tool. Don't skip it — it's where the real insight lives.
How many sample chats for a report? For the composite "full report" flow (§8.0): 1 chat for a quick check (illustrative colour), 1 chat for a standard report (from the highest-mention-count engine — the most representative slice), 3 chats for a deep dive (one per active engine to capture per-platform variation). For a standalone research session rather than a report, pull as many as budget allows and compare fanout patterns across prompts.
8.4 Fix the competitor list after project creation
1. list_brands → note the auto-selected competitors
2. get_domain_report(high limit, exclude UGC) → find CORPORATE domains with
high retrieved_percentage and mentioned_brand_ids that do NOT overlap
with list_brands.
3. For each, create_brand with {name, domains: [domain], aliases: [...],
optional regex}. Confirm with user first if multiple brands.
8.5 Add a tagged prompt set
1. create_tag(name="branded", color="blue") → note tag_id
2. For each desired prompt:
create_prompt(text=..., country_code=..., topic_id=..., tag_ids=[tag_id])
3. Verify with list_prompts(tag_id=tag_id)
8.6 Audit what AI is recommending as products
1. list_shopping_queries(project_id, date range) → see actual SKU-level
recommendations
2. Cross-reference with your product catalogue — are competitor products
appearing where yours should be?
3. Use get_chat on the parent chat for context on why.
8.7 Full project tune-up (dry-run first)
A tune-up is a systematic overhaul of an under-performing Peec project: replacing wrong brands, retagging prompts, deleting non-commercial prompts, adding revenue-informed new prompts. Load the companion peec-ai-project-tuneup skill for the methodology.
Minimum viable sequence, all captured in a dry-run document before any writes:
# Capture current state (no writes)
list_brands, list_topics, list_tags, list_prompts — dump to an audit file
get_brand_report, get_domain_report, get_url_report — 30-day slices
list_chats + get_chat samples — qualitative
# Design target state (external research, no Peec calls)
# — external data: GSC queries, rank tracking, GA4 revenue, content inventory
# — produce: tag taxonomy, brand roster, prompt allocation table
# Dry run document — every call listed in execution order
# Execute in waves (see §6.5)
# Wave 1 — additive, zero risk
create_tag × N, create_brand × N
# Wave 2 — mutate existing
update_prompt (tags + topic) × N, delete_brand × N, delete_prompt × N
# Wave 3 — additive, final state
create_prompt × N (each with topic_id and tag_ids pre-resolved)
# Post-execution
list_prompts with an explicit high limit, OR paginate offset=0,100,…
until rowCount=0 — confirm final prompt count equals
(initial − deleted + created). See §7.18.
list_tags / list_brands → confirm final entity counts.
get_brand_report after 7 days → early signal.
Why dry-run first: deletions are destructive, tag-set replacements are not append, and the plan often changes after the client reviews it. A single dry-run doc is also a great handover artifact for the client.
Count reconciliation as the completion check. Track the arithmetic: initial prompt count − deletes + creates = expected final count. Verify with a paginated list_prompts read (see §7.18). This is faster and more reliable than spot-checking individual operations.
8.8 Approximate get_actions locally (workaround recipe)
get_actions is broken (§7.12). This recipe approximates its output using the tools that do work. Use it whenever a user asks "what specific actions should we take to improve visibility?" or anything that maps to Peec's Actions feature.
# 1. Establish baseline and scope
list_projects → pick project_id
list_brands → own_id (is_own=true), competitor_ids
list_models → active_model_ids (is_active=true — §7.1)
# 2. Own-brand baseline (so recommendations are scale-aware)
get_brand_report(project_id, start_date, end_date,
filters=[{field: brand_id, operator: in, values: [own_id]}],
dimensions=[model_id])
→ current visibility, SoV, sentiment, position per engine.
Apply §7.37 scaling before reading any numbers.
# 3. EDITORIAL gap (competitor-heavy pages where we're absent)
get_url_report(project_id, start_date, end_date,
filters=[{field: gap, operator: gte, value: 2}],
limit=20)
→ high-retrieval pages where ≥2 competitors appear and own brand doesn't.
These are the EDITORIAL action candidates: pages to get listed on,
outreach targets, PR/digital-PR opportunities.
# 4. OWNED gap (our own pages underperforming relative to peers)
get_url_report(project_id, start_date, end_date,
filters=[{field: url_classification, operator: in,
values: ["OWN"]}],
limit=20)
→ our own pages that are retrieved but rarely cited, or retrieved
on fewer engines than comparable competitor pages.
Cross-reference citation_rate (not retrieval_rate — §7.37 Rate type;
values can exceed 1.0) against the leaders in the EDITORIAL gap set.
Low citation_rate on OWN pages = OWNED action candidates:
pages to restructure, clarify, or rewrite to earn citations.
# 5. Domain-level source authority (who else is being cited heavily?)
get_domain_report(project_id, start_date, end_date,
filters=[{field: gap, operator: gte, value: 1}])
→ source-authority rivals. Used for context, not direct actions.
# 6. Synthesise into a prioritised list
- Group EDITORIAL gaps by likely outreach vector (listicle, comparison,
how-to, reference source).
- Group OWNED gaps by page-type (category, product, blog, recipe — map
to the classifications returned in step 4).
- Prioritise by retrieval volume × number of competitors present × gap
delta (own 0 vs competitor N). Top 5–10 items become the action list.
# 7. Present with qualitative colour
Pull 1–2 chats via list_chats + get_chat that show the AI engine
choosing a competitor on a high-value prompt. This is what turns a
data list into an actionable narrative.
What this recipe does NOT do (and why that's OK): it doesn't produce Peec's exact url_classification action types (OWNED/EDITORIAL/TECHNICAL). It produces the two categories that matter most in practice (EDITORIAL, OWNED) and skips TECHNICAL — which covers crawlability/indexing issues that usually come from external SEO tooling (Screaming Frog, Sitebulb, searchVIU) anyway, not from Peec data.
Expected runtime: 6–9 MCP calls for a standard project. If the user then asks for "even more detail", chain §8.3 to pull sample chats from the action-list URLs so they can see the AI's actual framing of the competitive landscape.
8.9 Full competitive gap analysis (composite)
When a user asks "where are we losing to competitors?" or "what's the competitive landscape look like?" — this is the recipe. It combines roster benchmarking (§8.1 step 5), source authority (§8.2a), URL-level editorial gaps (§8.2b), and per-engine head-to-head (§8.1 head-to-head block) into a single flow that produces a three-layer narrative.
# Layer 1 — "How do we rank overall?"
get_brand_report (no dimension, no filter)
→ full-roster ranking on visibility / SoV / sentiment / position.
Identify the 2-3 competitors closest to or ahead of own brand.
# Layer 2 — "Where does the gap come from?"
For each identified competitor, run the §8.1 head-to-head pattern
(two calls, one filtered to own_id + dimensions=[model_id], one
filtered to competitor_id + dimensions=[model_id]; brand_id is a
filter, not a dimension). Pivot client-side by model_id.
Find the engines where the gap is widest (usually one or two dominate).
# Layer 3 — "What specifically is causing it?"
get_domain_report(filters=[{gap >= 1}], limit=20)
→ which source domains cite competitors but not us?
get_url_report(filters=[{gap >= 2}], limit=20)
→ which specific pages include competitors and exclude us?
# Optional Layer 4 — "What does a losing chat look like?"
list_chats(brand_id=closest_competitor_id, limit=10)
→ pick a chat where the competitor appears and own brand doesn't.
get_chat → read the actual AI response.
This reveals the narrative framing Peec doesn't visualise directly.
Output framing. Write this up as three stacked sections: (1) the roster gap ("we're at position 2.4; competitor X is at 1.6"), (2) the engine asymmetry ("the gap is concentrated on ChatGPT, not Grok or AI Overview"), (3) the concrete editorial and source-level drivers ("competitor X is cited on 4 high-retrieval listicles we're absent from"). Close with one AI-response excerpt that shows the actual prose the engine produced.
Typical length: 12–15 MCP calls. Budget 10 minutes of human-reading time for a written report at this depth.
8.10 Source authority audit (composite)
Answers "what sources is the AI reading, how authoritative are they, and which ones are citing competitors but not us?". Feed this into external content strategy (what to pitch, which outlets to court, what internal content to rewrite).
# 1. Top cited domains across the project
get_domain_report(project_id, start_date, end_date,
dimensions=[],
sort by retrieved_percentage desc, limit=30)
→ who are the AI engines actually reading?
# Note on the sort column: the undimensioned domain report returns
# retrieved_percentage (Ratio, 0–1), retrieval_rate (Rate, can exceed 1.0),
# and citation_rate (Rate) — but does NOT expose retrieval_count as a
# sortable column in the default response. Always sort the breadth view
# on retrieved_percentage. retrieval_count and citation_count DO appear on
# dimensioned or URL-level responses (§7.39), where you can sort on them
# directly. If a sort-by-count call returns a schema error or empty rows,
# fall back to retrieved_percentage.
# 2. Classification mix
Same call; group rows by classification (§7.30 — 8 values).
Report the share per bucket: CORPORATE, COMPETITOR, OWN, UGC,
REFERENCE, INSTITUTIONAL, LISTICLE, OTHER (approximate names —
check §7.30 for the exact enum).
UGC + REFERENCE together often account for a large share of
retrievals — useful framing data.
# 3. Authority-vs-citation sanity check
Read citation_rate column (not retrieval_rate; §7.37 Rate type —
values can exceed 1.0). Sort descending.
Domains with high retrieval_count + low citation_rate are "skimmed
but not quoted" — weak authority signal despite frequent retrieval.
Domains with low retrieval_count + high citation_rate are "quoted
when reached" — strong per-visit authority.
# 4. Gap layer — who's citing competitors but not us?
get_domain_report(filters=[{gap >= 1}], limit=20)
get_url_report(filters=[{gap >= 2}], limit=20)
Intersect with step 1: are any of the top-30 retrieved domains
also in the gap list? Those are the highest-leverage outreach
targets — they're already authoritative AND already citing
competitors.
# 5. Own-domain health check
get_domain_report with filters=[{url_classification OR domain
classification = OWN}]
→ our own domains' retrieval + citation rates over the period.
If citation_rate is low despite high retrieval_rate, we have an
internal content problem (§8.8 OWNED action path).
Key interpretive note. Don't confuse retrieval_rate and citation_rate: both are Rate type (§7.37) and can exceed 1.0. A citation_rate of 1.8 on a domain means "on average, 1.8 distinct URLs from this domain are cited per chat that cites anything from this domain". It's a per-visit density measure, not a "share of chats" ratio. If you write "cited X% of the time", you'll be wrong in a way that superficially reads right.
Deliverable shape. Four stacked findings: (1) overall source authority landscape with classification breakdown, (2) own-domain citation health, (3) competitor-cited-not-us outreach list, (4) one surprise from the long tail (e.g. an INSTITUTIONAL domain that unexpectedly dominates citation_rate). This mirrors what a manual SEO source-authority audit would produce — but built from AI-engine retrieval data, not Google's index.
Typical length: 5–8 MCP calls. Shortest of the composite recipes.
8.11 Test entity lifecycle (safe experimentation pattern)
When an agent needs to demonstrate write-tool behaviour, stress-test schema edge cases, or run an exploratory workflow without polluting real data, use this lifecycle.
# 1. Capture baseline (exact snapshot)
list_brands, list_topics, list_tags, list_prompts (limit=10000 — §7.18)
→ save IDs and key fields to an in-memory baseline dict.
# 2. Prefix every test entity
create_brand(name="STRESSTEST-Rival")
create_tag(name="STRESSTEST-branded", color="blue")
create_topic(name="STRESSTEST-EN")
create_prompt(text="STRESSTEST: what are the best X in Y?", …)
The "STRESSTEST-" prefix is searchable, filterable, and impossible
to confuse with real entities. Pick any prefix; stick to one.
# 3. Run the experiment
Perform the writes, reads, verifications, etc.
# 4. Revert in reverse dependency order
delete_prompt(test prompt IDs) — must come before tag or topic delete
if the prompts reference them (otherwise you orphan the reference).
delete_tag(test tag IDs)
delete_topic(test topic IDs)
delete_brand(test brand IDs)
# 5. Verify clean revert
list_brands, list_tags, list_topics, list_prompts (limit=10000)
→ assert counts match baseline; assert no "STRESSTEST-" prefix
remains anywhere.
Why ordering matters. Peec's soft-delete (§7.34) preserves historical chat data but removes the entity from list_* results. Deleting a tag while prompts still reference it leaves the prompt in a valid state (the tag's absence from list_tags doesn't break list_prompts), but it becomes impossible to audit what the test tag was attached to. Reverse-order deletes keep the audit trail intact.
Baseline match ≠ zero residual. A quiet bug: the prompt count returned to baseline but one of the baseline prompts had its tag_ids mutated during the test and wasn't reverted. Prefer capturing all field values (not just IDs) on any entity you mutate, and diff-verify those fields back to baseline after revert — not just counts. Count-only verification is necessary but not sufficient.
When to use a separate test project instead. For very-large-scale stress tests (>50 write operations) or anything that might hit rate limits, spin up a dedicated Peec project rather than prefixing entities in the live one. The test-prefix pattern is for single-session, <50-op experimentation.
9. Contributing
Spotted a discrepancy between this skill and Peec's actual behaviour? Discovered a new tool, parameter, or slash-command prompt? Hit a setup quirk in a client not yet covered? Please contribute back:
- Issues and PRs: github.com/rebelytics/peec-ai-mcp
- Workflow: See
CONTRIBUTING.mdin this repo for what's in scope and how to submit changes.
The goal is that anyone pulling this skill six months from now gets behaviour grounded in current reality, not an April 2026 snapshot. Contributions are the mechanism that keeps that promise.
10. License & attribution
License: CC BY 4.0. Reuse, adapt, redistribute — just keep attribution.
Attribution:
Peec AI MCP Companion Skill, maintained by Eoghan Henn (rebelytics.com), github.com/rebelytics/peec-ai-mcp.
Not affiliated with Peec AI. Peec's team has not reviewed or endorsed this skill.