MCP server
Writing through MCP
Write tools ship in six families:
- Brands, prompts, topics, tags and classifications — creating and updating the things a workspace measures.
- Workspace configuration — the collection schedule, the daily watchlist, prompt tagging and deduplication, the brand profile, the workspace itself, and topics derived from the profile.
- Agency writes — capping how much of an organization-wide plan one client workspace may draw.
- Starting collections — the four tools that start work running outside your call, and the only ones that spend collection budget.
- Destructive writes — deleting what the first family creates, ending a run, and managing keys and people. Every one of these takes an approval token; see Approvals.
- Keyword intelligence — researching what people actually search Google for, and turning that demand into candidate questions. Three of these spend keyword credits; see Keyword intelligence.
Every one of them shares one contract: the same envelope shape, the same
dry_run preview and the same idempotency_key retry safety. A tool's own
description only adds what is specific to it.
The bulk contract
The first family is bulk-only: you always send items, even for a single
change. Most of the tools in the other four take their arguments directly
and return one row instead — they still take dry_run and
idempotency_key, and everything below about those two applies to them
unchanged. Each table says which shape a tool takes.
items takes 1–50 entries; each is applied independently inside one
transaction — one item failing its own validation does not stop the others
from committing. Every write tool returns the same columnar envelope every
read tool does: columns = ['index','id','status','reason'], one row per
item in input order, totals = { created, updated, skipped, rejected, dry_run }, and a trace_id. dry_run: true returns the identical shape
with totals.dry_run set to 1.
Errors: a schema violation on items (wrong type, missing field, an item
outside 1–50) is the SDK's own validation error, before anything runs.
conflict is an idempotency_key reused with different arguments.
forbidden_scope and forbidden_role come from the same authorization
chain every tool goes through. No plan cap applies to these tools today. If
one is added, it will reject per item with limit_reached rather than
failing the call, so a 50-item import would return whatever fit and tell you
which rows did not.
dry_run
dry_run: true runs the real per-item logic and rolls it back inside the
same database transaction — an exact preview, not a simulation. The rows
you get back are the same rows a real call would produce, id and all;
nothing is written. Omit dry_run (or send false) to actually commit.
idempotency_key
An optional string, 8–128 characters. Send the same key again with the
exact same arguments and you get back the first call's response, verbatim
— nothing runs twice. Send the same key with different arguments and the
call fails with conflict: a reused key is never silently treated as a new
call. A stored response is served for 24 hours from the first call; after
that, the key is free to reuse for something new.
Two calls sent at the same moment with the same new key are the one case
this does not smooth over: one succeeds, the other fails; retry the failed
one and it replays the first one's response. For the four run-starting
tools the second one fails with conflict saying the first call is still
in progress — they claim the key before they open a run, so a duplicate
never starts a second one — and a retry a moment later replays the first
call's row.
Approvals
Fourteen tools will not run on their own. Each one takes an
approval_token, minted by request_approval for that exact call, and
refuses without one:
Every call shape below also takes the required top-level workspace_id
that every write tool takes; it is left out of the table only to keep the
shapes readable, and the worked exchange further down shows it in place.
| Tool | Scope | Call shape | What it does that needs approving |
|---|---|---|---|
delete_prompts | prompt:write | items: [{ prompt_id }] | The prompt is gone from every list, count and schedule. Its answers and history stay. On a trial the lifetime slot stays spent. |
delete_topics | prompt:write | items: [{ topic_id }] | Every prompt on the topic moves to general; detached counts them (archived and deleted ones included). |
delete_tags | prompt:write | items: [{ tag_id }] | The tag comes off every prompt that carried it. Which prompts those were is not recoverable. |
delete_tag_groups | prompt:write | items: [{ group, delete_tags? }] | Ungroups the group's tags, or deletes them too with delete_tags: true; tags_affected counts them. |
remove_competitors | brand:write | items: [{ brand_id }] | Stops tracking a competitor. Your own brand is refused. |
delete_source_classification | brand:write | items: [{ kind, name }] | Deletes a custom class and clears every override pinned to it; overrides_cleared counts them, measured before the delete. |
unassign_source_classification | brand:write | items: [{ kind, target }] | Drops one target's pinned class so it falls back to automatic classification. |
finalize_prompt_candidates | prompt:write | { candidate_ids[], decision } | Accepts candidates into the workspace (which spends prompt slots and, on a trial, lifetime ones) or rejects them. |
cancel_run | run:cancel | { run_id } | Stops queued work for a run. In-flight collections still land and are still paid for; reserved allowance is released and answers_released reports how much. |
create_service_account | member:manage | { target_workspace_id, label, scopes[] } | Mints a new API key. The key is returned once and never again. |
revoke_mcp_connection | member:manage | { key_id } | Kills a key immediately. Anything using it stops working. |
invite_member | member:manage | { workos_user_id, role, workspace_allowlist? } | Adds a person to the organization and takes a seat. |
update_member_role | member:manage | { user_id, role?, workspace_allowlist? } | Changes what a person may do. |
revoke_member_access | member:manage | { user_id } | Removes a person from the organization. |
The five member:manage tools are additionally person-only: an API key
never sees them in tools/list, and a direct call refuses
forbidden_role. They are reachable only by an authenticated person.
Each tool's own description carries its statuses and refusal reasons, the
same way every other write tool's does. Nothing here changes the bulk
contract, dry_run or idempotency_key — a gated tool is an ordinary
write tool with one extra required argument.
The exchange
Two calls. First, ask for approval, passing the target tool's name and the exact arguments you intend to send it:
{
"tool": "delete_topics",
"arguments": {
"workspace_id": "…",
"items": [{ "topic_id": "6f2a…" }]
}
}
request_approval runs delete_topics as a real dry run — the same
code path, in the same transaction, rolled back — and hands you its actual
impact plus a token:
{
"columns": ["tool", "approval_token", "expires_at", "impact", "estimated_usage"],
"rows": [[
"delete_topics",
"eyJ2IjoxLCJhY3Rpb25faGFzaCI6…",
"2026-09-10T14:31:07.000Z",
{
"columns": ["index", "id", "status", "reason", "detached"],
"rows": [[0, "6f2a…", "updated", null, 41]],
"row_count": 1,
"totals": { "created": 0, "updated": 1, "skipped": 0, "rejected": 0, "dry_run": 1 }
},
null
]],
"row_count": 1,
"trace_id": "…"
}
impact is the dry run's own envelope, so "41 prompts get detached" is a
number the tool computed, not an estimate. estimated_usage is null for
every gated tool in this release — the column exists for an approved action
that has to state what it will spend before it spends it, and none of these
does; a deletion costs nothing, and finalize_prompt_candidates' slot
check re-runs at execution anyway.
Show that impact to a human. Then send the identical call with the token added:
{
"workspace_id": "…",
"items": [{ "topic_id": "6f2a…" }],
"approval_token": "eyJ2IjoxLCJhY3Rpb25faGFzaCI6…"
}
You get the normal write envelope back. A dry_run: true call needs no
token — that is how request_approval previews in the first place.
The token is valid for ten minutes (expires_at says exactly when),
may be used once, and is bound to the arguments, the connection that
minted it and the workspace it was minted for. request_approval also
runs the target tool's own scope, role and workspace checks against you
before it previews anything, so a token you could not redeem is never
minted — and a tool you may not call never shows you its impact.
The four refusals
| Code | What happened | What to do |
|---|---|---|
approval_required | No approval_token on a call that needs one. | Call request_approval with these exact arguments. |
approval_invalid | The token is not valid for this call: a bad signature, an unparseable payload, or a token minted for different arguments, a different connection or a different workspace. detail.reason says which. | Mint a new token for the call you are actually making. |
approval_expired | More than ten minutes since it was minted. detail.expires_at says when it lapsed. | Mint a new one and redeem it promptly. |
approval_replayed | That token has already been used. | Mint a new one if you really meant to run the tool a second time. |
upstream_unavailable appears in two more cases, and neither means your
token was wrong: the replay store is unreachable (the call is refused
without running rather than run without replay protection), or this server
has no signing secret configured at all. In the second case the gated tools
are normally absent from tools/list entirely — describe_capabilities
reports them as unavailable rather than leaving you to guess.
Two traps
idempotency_key is part of the approved action. The token binds the
arguments, and idempotency_key is one of them. So minting without a key
and redeeming with one refuses approval_invalid
(reason: action_mismatch) — mint and redeem with the identical
argument object, key included. The corollary bites harder: because the
token is single use, a call that fails after the token was spent needs a
fresh token to retry, even when the action is perfectly idempotent and
you are resending the same idempotency_key. Mint again with that same
key: if the first call had actually applied and only its response was lost,
the retry replays that stored response. If the first call FAILED, there is
nothing stored to replay — the response is recorded on success only — so
the retry runs the work for real. Either way you get the one outcome you
asked for, which is the point of the key; what the retry does under the
hood differs.
context and fields are not part of the approved action. Along with
approval_token and dry_run, they are excluded from the hash, so you may
add, change or drop them between minting and redeeming. Everything else is
bound: change one domain argument and the token stops working.
What this actually protects against
Be clear-eyed about the mechanism, because it is easy to read it as more than it is. The agent asking for approval is the agent redeeming it. Nothing here verifies that a human saw the impact statement, and nothing here obtains a second party's permission. A token records a decision the caller made about a call the caller is about to make.
What it does buy, and these are worth having:
- A forced round trip. A destructive call cannot happen in one step, so there is a place for a human to be shown something.
- A legible impact statement, produced by the real code path rather than an estimator that can drift from it — "41 prompts detached", not "deletes a topic".
- A short window. Ten minutes, so an approval cannot be banked and redeemed against a workspace that has since changed.
- A single use. One token, one execution; a replay is refused rather than silently repeated.
- A re-check at execution. Scope, role, workspace and quota are all checked again when the token is redeemed, not just when it was minted — so an approval granted while a seat or a slot was free still refuses if it is gone by the time you redeem.
That is protection against a confused agent and a stale plan. It is not protection against a hostile one: an agent that means to delete your prompts can mint its own approval and redeem it. Do not build a policy on a stronger claim than that.
The tools
Brands, prompts, topics, tags and classifications
| Tool | Scope | Item shape | Statuses |
|---|---|---|---|
add_competitors | brand:write | { name, domains[] (1–10, bare hostnames), aliases[]?, regex?, color? } | created / updated / rejected (self_brand) |
update_brands | brand:write | { brand_id, name?, domains[]?, aliases[]?, regex?, color? } (at least one) | updated / rejected (not_found) |
import_prompts | prompt:write | { text, topic_id? (slug, default general), tag_ids[]?, country_code?, geo_local? } | created / updated / rejected (unknown_topic, unknown_tag, market_scope_required, location_conflict; a geo_local: true item also geo_local_requires_local_market, geo_location_unsupported, geo_cap_exceeded, geo_trial_unavailable, geo_plan_required, geo_local_collection_disabled) |
archive_prompts | prompt:write | { prompt_id } | updated / skipped (already archived) / rejected (not_found) |
unarchive_prompts | prompt:write | { prompt_id } | updated / skipped (not archived) / rejected (not_found; a geo_local prompt also geo_cap_exceeded, geo_trial_unavailable, geo_plan_required) |
update_prompts | prompt:write | { prompt_id, topic_id?, tag_ids[]? } (at least one) | updated / rejected (not_found, unknown_topic, unknown_tag) |
update_prompt_candidates | prompt:write | { candidate_id, text?, system_tags[]? } (at least one) | updated / rejected (not_proposed, not_found) |
create_topics | prompt:write | { name, slug?, country_code? } | created / updated (slug already exists) / rejected (market_scope_required, location_conflict) |
update_topics | prompt:write | { topic_id (uuid — the row's own id, not the slug), name } | updated / rejected (not_found, market_scope_required, location_conflict) |
create_tags | prompt:write | { name, color?, group? } | created / updated (name already exists) / rejected (system_tag) |
update_tags | prompt:write | { tag_id, name?, color?, group? } (at least one) | updated / rejected (not_found_or_system, system_tag, name_taken) |
update_tag_groups | prompt:write | { group, name?, color? } (at least one) | updated / skipped (0 rows touched) |
create_source_classification | brand:write | { kind (domain or url), name, color? } | created / updated (name already exists in that kind) |
assign_source_classification | brand:write | { kind, target, classification } | updated / rejected (unknown_classification) |
Two rows are not patches in the way the rest are:
add_competitorsis full-replace onname/domains, but not on every field. It derives the brand id from the first domain, so calling it again for a domain you already track updates that same row rather than duplicating it. Send bare hostnames —acme.com, nothttps://acme.com/— since that derivation is what keeps a re-add from duplicating a row.nameanddomainsare always replaced with whatever you send.aliasesis also unconditional — re-adding a tracked domain withoutaliasesresets it to[].regexandcolor, by contrast, are kept when you omit them; they only change when you send a value. A domain that resolves to your own brand is rejected withself_brandrather than quietly turning your brand into a competitor — edit it withupdate_brands.update_brandsis a patch. Only the fields you send change; anything omitted keeps its current value. It never touches whether the brand is the tenant's own brand (is_self) or how it was created (source).
Three more things worth knowing before your first import:
- An existing prompt whose text matches is
updatedeven if it is archived; unarchive it withunarchive_promptsor it will not run. tag_idsmust name your own tags.import_promptsandupdate_promptsreject an id that names no tag withunknown_tag, and the five system tags (branded,non-branded,informational,commercial,transactional) count as unknown here: they are derived from the prompt's text, not assigned. Nothing is written for a rejected item.update_tagslikewise rejects a rename onto another of your tags withname_taken— tag names are unique.- Two tools hand back an
idyou supplied, not a row id.assign_source_classificationreturns thetargetyou sent (an override has no row id of its own) andupdate_tag_groupsreturns thegroupname. Those values are part of the response stored under anidempotency_key, so a replay hands your own label back to you verbatim.
topic_id is the topic's slug everywhere except update_topics, which
addresses the topic row itself and takes its uuid.
import_prompts, create_topics and update_topics compile text/name
through the workspace's confirmed market, exactly like a generated
question or topic does (local/national AEO Task 11): the value you sent is
stored as base_text/base_name (list_prompts, export_prompts and
list_topics expose it as separately labelled provenance), and the
effective, localised value — what actually gets stored and returned —
is what a local workspace's confirmed city/state compiles it to. A
workspace with no confirmed market at all refuses the item with
market_scope_required (set one first with set_market_profile), and text
that names a different city than the one confirmed — or any city at all,
for a national workspace — refuses with location_conflict. No argument,
and no wording inside text itself, can turn this off: the enforcement
runs on the stored, confirmed market, never on anything the call claims.
import_prompts with geo_local: true writes a location-tracked prompt
instead of compiling a city into its text. It is asked from every tracked
location of a LOCAL workspace (see list_market_locations), on ChatGPT and
Google AI Overview only — never Gemini, Google AI Mode or another engine,
whatever the workspace's engine list — so its text must not name a place. Each such item is checked on its own: a national
workspace refuses it with geo_local_requires_local_market, a workspace
with no location that can be geolocated with geo_location_unsupported,
and the organization's 10% local-tracking allowance (active geo_local
prompts × active locations, across every workspace) with
geo_cap_exceeded; a trial refuses geo_trial_unavailable and an
organization with no active plan geo_plan_required. While location
collection is off for the workspace (GEO_LOCAL_COLLECTION, or the
workspace is not on the pilot allowlist), every geo_local item refuses
geo_local_collection_disabled — there would be nothing to ask it.
unarchive_prompts checks a geo_local prompt against the same
allowance, because it puts its locations back into collection.
add_to_daily_watchlist never accepts a geo_local prompt
(geo_daily_watchlist_unavailable: local prompts are tracked at most once
a week per engine since 2026-10-14), and accepts no prompt at all in a
LocalAEO workspace (daily_watchlist_unavailable_on_plan).
Workspace configuration
Eight tools for how a workspace collects, rather than what it collects.
Three take items; the other five take their arguments directly.
| Tool | Scope | Shape | Result |
|---|---|---|---|
set_weekly_schedule | prompt:write | { enabled?, cadence?, weekday?, hour_utc?, engines? } (at least one) | one row enabled, cadence, weekday, hour_utc, engines — the schedule as stored after the write |
add_to_daily_watchlist | prompt:write | items: [{ prompt_id }] | created / skipped (already on it) / rejected (not_found, limit_reached, geo_daily_watchlist_unavailable for a geo_local prompt, daily_watchlist_unavailable_on_plan in a LocalAEO workspace) |
remove_from_daily_watchlist | prompt:write | items: [{ prompt_id }] | updated / skipped (not on it) / rejected (not_found) |
tag_prompts | prompt:write | items: [{ prompt_id, tag_ids (1–20) }] | updated / rejected (not_found, unknown_tag) |
deduplicate_prompts | prompt:write | { threshold? (0.7–1, default 0.85) } | one row per duplicate group: keep_prompt_id, keep_text, archived_prompt_ids, archived_count, similarity_min; totals carry clusters and archived |
generate_topics | prompt:write | { country_code? } | one row per theme: topic_id, name, status (created or exists); refuses no_profile, market_scope_required |
update_brand_profile | brand:write | { industry?, icp?, use_cases?, features?, integrations?, verticals?, pricing_model?, vocabulary? } (at least one) | one row profile_id, created_at, changed_fields; refuses no_profile, invalid_profile |
update_workspace | workspace:write | { display_name?, official_origin? } (at least one) | one row workspace_id, display_name, official_origin; rejected (not_found) |
set_weekly_schedule is a patch on one row. There is exactly one
schedule per workspace, created with defaults on first use, so status is
always updated and every field you omit keeps its stored value.
cadence: 'weekly' collects once a week, on weekday (0 = Sunday through
6 = Saturday) at hour_utc (0–23); cadence: 'daily' collects every day
at hour_utc and ignores weekday entirely. engines takes public engine
names and replaces the current set rather than adding to it — sending
['chatgpt'] turns every other engine off — and google_ai means both
Google channels. enabled: false pauses collection without losing any of
the settings. Read the result back at any time with get_workspace.
deduplicate_prompts archives prompts, so run it as a preview first.
It groups near-identical active prompts by how alike their text is and
archives every prompt in a group except the oldest — the one with the
longest measurement history, which is kept. Call it with dry_run: true
first: that returns exactly the groups it would collapse, and archives
nothing. threshold is how alike two texts must be to count as duplicates,
so raise it toward 1 to collapse only near-exact pairs and lower it toward
0.7 to be more aggressive. Nothing it does is permanent: every archive it
makes is reversible with unarchive_prompts.
generate_topics requires a confirmed market (local/national AEO Task
11). It used to fall back to a synthetic national profile for a workspace
with nothing confirmed, because refusing then would have been enforcement
with no remedy — there was nowhere yet to go set one. set_market_profile
is that remedy, so the fallback is gone: an unconfirmed workspace now
refuses the whole call with market_scope_required instead of silently
generating placeless topics.
The outcome words are the same three everywhere. These tools describe
adding, removing and tagging, but they report it in the one status
vocabulary every write tool on this page uses: a prompt newly added to the
watchlist is created, one that was already on it is skipped, and a
removal or a tagging is updated. Only a refused item carries a reason.
Recommended actions
One tool over the workspace's recommended actions (read them with
recommend_next_actions). It takes its arguments directly.
| Tool | Scope | Shape | Result |
|---|---|---|---|
update_action_item | report:write | { action_id, status: open | accepted | dismissed | in_review } | one row action_id, previous_status, action_status, status_changed_at; skipped (already there); rejected (not_found, not_movable_here, invalid_transition, forbidden_role, status_changed) |
Only the to-do moves — plus the gap moves. For a bundle action that has
gone past accepted — a question chosen, a page drafted, in review,
approved or published — the move is made in the studio (or by the
operator's aeomcp site answer), never here: not_movable_here says so.
Restoring a dismissed bundle action goes to open, not straight to
accepted. An answer-page gap action (a pre-written answer page for a gap
prompt) instead moves only between in_review and dismissed — Dismiss
and Show again — and in_review is only ever a valid target for a gap
action; targeting it on a bundle action is invalid_transition. An
approved or published gap action is not_movable_here (Apply and publish
are the studio's and the operator's). Client roles (client_admin,
client_viewer) see gap actions read-only: a move on one is rejected
forbidden_role; their bundle moves are unchanged. Every
move is stamped mcp:<token id> on the action's status_changed_by. The row's
action_status is the action's new status; the row's own status is the
usual updated / skipped / rejected.
Agency writes
One tool, and it is not available to an API key at all: it is hidden from
tools/list and refused on a direct call unless you are an organization
owner or admin signed in through OAuth. It acts across the
organization's workspaces rather than inside one, which is a decision a
person takes, not a machine credential.
| Tool | Scope | Shape | Result |
|---|---|---|---|
allocate_workspace_capacity | workspace:write | { target_workspace_id, dimension: 'weekly_question_slots' | 'daily_watchlist_questions' | 'ondemand_refreshes', ceiling: integer ≥ 0 | null } | one row workspace_id, dimension, ceiling; refuses not_in_organization |
ceiling is a whole number of that dimension's own units per period, and it
caps only how much of the shared organization-wide pool this one workspace
may draw. It neither adds capacity nor reserves any, and the organization's
own cap still applies on top of it. ceiling: null removes the cap so the
workspace draws freely from the pool again. A target_workspace_id outside
your organization is refused with not_in_organization — the same answer a
workspace that does not exist gets, deliberately.
Starting collections
Four tools start a round of work that runs outside your call. All four are
asynchronous: they return a run handle immediately and the work happens
afterwards, so poll
get_run or get_collection_status for progress and read the results with
search_answers once a run finishes.
| Tool | Scope | Shape | Refusals |
|---|---|---|---|
run_question_now | run:execute | { workspace_id, prompt_ids (1–50), engines?, max_quota_impact? } | market_scope_required, unknown_prompt, estimate_exceeds_max, organization_capacity_exhausted |
retry_failed_executions | run:execute | { workspace_id, run_id, max_quota_impact? } | run_not_found, nothing_to_retry, retry_too_large, estimate_exceeds_max, organization_capacity_exhausted |
onboard_brand | brand:write | { workspace_id, self_domain, competitor_domains (0–5), engines?, max_quota_impact? } | market_scope_required, not_entitled, bad_domain, too_many_competitors, already_running |
generate_buyer_questions | prompt:write | { workspace_id, recrawl? } | market_scope_required, not_entitled, no_profile, already_running |
All four return the same single row, whether they started anything or not, so a preview and a real call can be compared field by field:
run_id— the handle to poll, ornullon a dry runstatus—queued, ornot_startedon a dry rundimension— which kind of work this is (ondemand,onboarding,regen)prompts,engines,asks,estimated_usd— the estimate, the same numbersestimate_run_costreports for the same argumentsquota_dimension,quota_used,quota_limit— the metered pool this request draws on, ornullfor a tool that draws on none at request time
Their totals carry only dry_run, not the
created/updated/skipped/rejected counts every other write tool
returns: a run is one thing, not a list of items, so there is nothing to
count per item.
max_quota_impact is a bound in asks. Set it and the call is refused
with estimate_exceeds_max before anything is spent if the estimate comes
out above it — the safety belt for "start this, but not if it turns out to
be enormous". generate_buyer_questions does not take it: its estimate is
always zero asks (see below), so there is nothing for a bound to catch.
dry_run: true returns the estimate row and starts nothing — run_id
null, status not_started. It is a genuine preview rather than a
rolled-back write, because the work happens in another process where no
transaction reaches. That has one consequence worth planning for: a dry run
computes the estimate and checks your own arguments, but it never reaches
the request path, so it does not preview the refusals that path raises.
A call that dry-ran cleanly can still come back not_entitled,
bad_domain, already_running, no_profile or
organization_capacity_exhausted on the real attempt — treat a clean
preview as "the numbers are what I expected", not as "this will start".
A run that cannot be dispatched fails immediately and says so. If the
handoff to the workers breaks, the answer is upstream_unavailable with the
run_id in it, and that run has already been marked failed — nothing is
left sitting in a queue waiting for something that will never arrive. Retry
the call. In the other direction, if the response cannot be recorded once
the run has already started, retries with that idempotency_key answer
conflict for 24 hours: use a fresh key, and list_runs to find the run
that did start.
On-demand spend is metered per prompt, not per ask. The pool
run_question_now and retry_failed_executions draw on is counted in
refreshes — one per prompt, however many engines that prompt runs on —
while asks (and therefore estimated_usd and max_quota_impact) counts
prompt-and-engine pairs. Ten prompts across four engines is 40 asks and
10 refreshes. The pool is shared across the whole organization, and a
workspace can also have its own lower ceiling set by an agency admin;
either being exhausted refuses the call with
organization_capacity_exhausted, which carries the dimension, limit
and used. retry_failed_executions is an ordinary on-demand request in
this respect: a pair that failed never returned an answer, so nothing was
charged for it the first time, and retrying it draws on the pool exactly as
a fresh request would.
A retry is prompts × engines, not the failed pairs. It re-runs the
prompts that failed across the engines those failures happened on, so a run
where one prompt failed on one engine and another prompt failed on a
different engine retries four pairs, two of which already have answers —
and a re-asked pair overwrites the answer stored for it. The estimate row
states the full count it will ask, so read it (or call with dry_run: true
first) rather than assuming the round is limited to the failures. Because
you do not choose how big that round is, max_quota_impact is worth
setting here in particular; a retry whose failures span more than 50
prompts is refused outright with retry_too_large, which carries the
derived prompts count and the max.
onboard_brand and generate_buyer_questions report zero asks, and
that is not a rounding error. Both produce prompt candidates, not active
prompts. Nothing is collected and nothing is spent from the answer pool
until you review them with list_prompt_candidates and accept the ones you
want with update_prompt_candidates — so at request time there is no ask
count to report, and all three quota fields are null. The engine count in
the row is real; it is decided now. Accepting candidates is the step that
starts costing anything, and until you take it a freshly onboarded
workspace collects nothing.
onboard_brand takes bare hostnames (example.com, not
https://example.com/), and each one must resolve and answer before the run
opens — a domain that does not is refused with bad_domain, naming which
one and why. Only one onboarding or question-regeneration run may be in
progress per workspace at a time; a second is refused with already_running
and the run_id of the one already going.
All four now require a confirmed market first (local/national AEO Task
11). onboard_brand sets up a workspace for the first time, so this is
where a caller most often meets market_scope_required — call
set_market_profile before onboarding a brand-new workspace. The check
runs before anything else: no domain check, no estimate read, no write.
Market scope
Nine tools over the workspace's local/national analysis market and the
cities its location-tracked prompts are asked from — spec §13. Three are
reads (workspace:read: get_market_profile, preview_market_change,
list_market_locations); the rest are writes. set_market_profile,
request_market_change, add_market_location, remove_market_location
and set_primary_market_location are also hidden from
tools/list and refused on a direct call unless you are an organization
owner or admin signed in through OAuth — a scope change is a person's
decision, never a shared API key's.
| Tool | Scope | Shape | Result |
|---|---|---|---|
set_market_profile | workspace:write, personOnly | { scope: 'local' | 'national', state_code?, city_name?, country_code?, language_code?, expected_revision? } | the market block (see below); refuses market_scope_required (no scope given), local_market_required (local with no usable state/city), market_change_requires_preview (already confirmed, and this would move it to a different cohort), stale_market_revision |
preview_market_change | workspace:read | { workspace_id, scope, state_code?, city_name?, country_code?, language_code? } | { preview: { from, to, affected, conflicts[], proposals[], quotaImpact, cohortBoundary, previewHash, expectedRevision } }; refuses market_scope_required when nothing is confirmed yet to change FROM |
request_market_change | workspace:write, personOnly | { workspace_id, scope, state_code?, city_name?, country_code?, language_code?, expected_revision, preview_hash, idempotency_key? } | { id } — the id of the pending change; refuses stale_market_revision, pending_exists |
cancel_market_change | workspace:write | { workspace_id? } | { canceled: boolean } — idempotent, false when nothing was pending |
list_market_locations | workspace:read | { workspace_id? } | { enabled, cap, locations[] } — the primary and every added city, inactive (removed) rows included; location_id is what the Window's location_id filter takes; cap is the organization's local-tracking allowance and its use; enabled is whether location collection is on for this workspace (GEO_LOCAL_COLLECTION on, and the workspace on the pilot allowlist or the allowlist empty) |
add_market_location | workspace:write, personOnly | { workspace_id, locality_id } (a directory city, e.g. us-wa-tacoma) | { location, cap } — the added (or reactivated) location and the allowance after it; a direct write, no preview; refuses local_market_required, geo_location_unsupported, geo_cap_exceeded, geo_trial_unavailable, geo_plan_required, feature_disabled while location collection is off for this workspace (GEO_LOCAL_COLLECTION, or the workspace is not on the pilot allowlist) |
remove_market_location | workspace:write, personOnly | { workspace_id, location_id } | { removed: boolean } — deactivates, never deletes (past answers keep their location). removed: false — not an error — when the id is the PRIMARY location, an unknown id, or one already inactive; the primary mirrors the market and moves only through set_primary_market_location / request_market_change. Refuses feature_disabled while location collection is off for this workspace (GEO_LOCAL_COLLECTION, or the workspace is not on the pilot allowlist). |
set_primary_market_location | workspace:write, personOnly | { workspace_id, location_id, expected_revision, preview_hash, idempotency_key? } | { id } of the pending change — this IS request_market_change for that location's city; preview it first with preview_market_change (scope: 'local', the location's city_name and its two-letter state_code, e.g. Tacoma/WA); refuses not_found, invalid_argument (already primary), pending_exists, stale_market_revision, feature_disabled while location collection is off for this workspace (GEO_LOCAL_COLLECTION, or the workspace is not on the pilot allowlist) |
set_market_profile is the first-time setup surface, not a general
market editor. A workspace that has never confirmed a market (including
one with no tenant_markets row at all, or the migration's own
legacy_unconfirmed backfill) has nothing else to call —
onboard_brand, generate_buyer_questions and generate_topics all
refuse market_scope_required until this is called once. Local requires
state_code and city_name; omitting either refuses
local_market_required naming the missing field ({ field: 'stateCode' }
or { field: 'cityName' }).
Once a market is confirmed, this tool refuses
market_change_requires_preview for any request that would move it to a
genuinely different cohort — a different city, or local flipping to
national or back — and writes nothing. This is not a policy nicety: nothing
on this tool's write path recompiles a single stored prompt or topic, so a
direct write that changed the city would leave every existing prompt's text
still naming the OLD city, and the very next collection run would refuse
each one individually with location_conflict at dispatch time, with no
preview and no chance to fix anything first. Use preview_market_change
then request_market_change instead — that path recomputes every affected
prompt and topic and reports conflicts up front. A cosmetic re-save of
the same confirmed city (WA → Washington) is not a cohort change and
is still accepted here as an idempotent no-op, optionally guarded with
expected_revision for the same staleness protection
request_market_change's apply path gets.
preview_market_change / request_market_change are the revision-checked
change flow, modelled on the paid collection-mode switch
(preview_collection_mode_change / request_collection_mode_change):
preview to see what would change and get a previewHash/expectedRevision,
then request with both — a stale pair (the world moved since you previewed)
refuses stale_market_revision. proposals and conflicts never overlap:
a prompt whose text recompiles cleanly is a proposal (from/to text); one
that cannot (a customer-authored mention of a different city) is a conflict
the customer resolves by hand — nothing is ever silently rewritten or
dropped. quotaImpact.freshCollection is always false: a market change
revises how future work compiles, and applying one never starts a single
new collection call on its own. A second pending request while one is
already outstanding refuses cleanly with pending_exists (carrying the
existing request) rather than an opaque database error — cancel it or wait
for it to resolve first.
request_market_change only records the pending change; nothing here
applies it. There is no MCP tool that applies a pending market change —
an organization owner or admin applies it from the workspace settings UI,
which is what actually recomputes every affected prompt and topic. Until
that happens, get_market_profile/get_workspace keep reporting the
CURRENT (unchanged) market, and cancel_market_change withdraws the
request without ever having touched anything live. Say this to whoever you
are acting for: requesting a change is not the same as it taking effect.
Locations. The primary location mirrors the confirmed market, so it
moves only through the market-change flow: set_primary_market_location
records a pending change exactly as request_market_change would, applied
by an owner or admin in the studio, and its preview's locationsDeactivated
lists the cities the move switches off (re-add the ones you still want
afterwards). Adding and removing other cities are direct writes — no prompt
text changes and nothing is spent until a run asks from them. All three
location writes refuse feature_disabled while location collection is off
for the workspace — GEO_LOCAL_COLLECTION is off, or the workspace is not
on the pilot allowlist (list_market_locations.enabled is false); the
list itself is always readable.
The market block every read tool on this surface shares:
{ scope, displayLocation, countryCode, languageCode, measurementMethod, revision, policyVersion, physicalSearchLocation, needsConfirmation }.
physicalSearchLocation is always 'not_verified': this whole surface
configures which place name generated and imported text is measured
against, and proves nothing about where an engine actually searched from.
get_market_profile, get_workspace, get_account_context,
get_onboarding_status, get_client_report and get_report all carry it
— sourced from the workspace's current market, never a report bundle's
own frozen marketScope (a different question: what a past run was
authorized under, not what a future one would use).
Keyword intelligence
Available only when the deployment has keyword intelligence enabled —
otherwise the five tools are absent from tools/list and
describe_capabilities reports them available: false.
| Tool | Tier | Scope | Shape | Spends | Refusals |
|---|---|---|---|---|---|
discover_keywords | 3 | prompt:write | { workspace_id, site? | seeds? (1–20), market?, max_credits? } | credits (one per paid supplier call) | no_keyword_credits, supplier_unavailable, supplier_disabled, unsupported_market, estimate_exceeds_max, not_entitled |
get_keyword_volumes | 3 | prompt:write | { workspace_id, phrases (1–1000), market?, limit?, cursor? } | ≤ 1 credit; cache hits free | same, minus estimate_exceeds_max |
expand_topics | 3 | prompt:write | { workspace_id, topic_slug | seeds (1–20), max_questions?, market?, max_credits? } | credits (the research step only) | as discover_keywords, plus already_running, no_profile, not_found, market_scope_required, location_conflict |
get_discovery_status | 1 | prompt:read | { workspace_id, run_id, intents?, limit?, cursor? } | nothing | not_found |
link_prompt_keyword | 2 | prompt:write | { workspace_id, prompt_id, group_id | null } | nothing | not_found, unknown_group, unsupported_market |
The three spenders are run-shaped like Starting collections:
dry_run previews and reserves nothing, idempotency_key replays, and a
credits block beside the envelope reports reserved, spent and
remaining (null = unlimited). Without an idempotency_key, an identical
call made while another is still in flight is refused with conflict —
retry it a moment later. max_credits is a hard bound: a call whose planned
credits exceed it is refused with estimate_exceeds_max before anything is
reserved or started. no_keyword_credits carries remaining: 0 and
resets_at — the first of next month, or null on a trial, whose credits
are lifetime. supplier_unavailable with cause: outcome_unknown is the one
refusal that may have been billed: the supplier's answer was lost, and the
identical request is refused for an hour rather than bought twice.
expand_topics never creates live prompts: its questions arrive as
candidates under run_id for finalize_prompt_candidates.
expand_topics generates questions, so it takes the same market gate as
onboard_brand, generate_buyer_questions and generate_topics: it
refuses market_scope_required when the workspace has no confirmed
local/national market, and location_conflict when a seed names a
different place than the confirmed market (or, for a national workspace,
any place or near-me wording) — both before any keyword credit is spent.
Its candidates are compiled through the confirmed market on accept like
every other candidate (a conflicting one stays proposed, counted
market_skipped).
Every volume anywhere in this server is Google search demand for the
linked keyword — how often people search Google for it — not how often AI
engines are asked the prompt. null means not measured, never zero.
What is not here
The deletes, finalize_prompt_candidates, cancel_run and the key and
membership tools have arrived — they are the destructive family, and they
are documented under Approvals above rather than here.
Still absent: bulk_onboard_brands, reorder_daily_watchlist,
revoke_service_account (use revoke_mcp_connection, which names a key by
its own id), the webhook tools, and creating a client workspace, which is
still a console action. See peec-to-aeomcp for the full mapping and which
names are only reserved so far.
Worked calls
Create tags, previewed first:
{
"workspace_id": "…",
"items": [
{ "name": "Editorial", "color": "#4C6FFF" },
{ "name": "Sponsored", "group": "content-type" }
],
"dry_run": true
}
Returns two rows, status: "created" for both, totals: { created: 2, updated: 0, skipped: 0, rejected: 0, dry_run: 1 } — and nothing was
written. Drop dry_run (or set it false) and send the identical call
again to actually create them.
Import a prompt, safe to retry:
{
"workspace_id": "…",
"idempotency_key": "import-poise-general-2026-09-08-01",
"items": [
{ "text": "What is the best adult incontinence brand?", "topic_id": "general", "country_code": "US" }
]
}
The first call creates the prompt and stores the response under that key.
If the network drops before you see the response and you send the exact
same call again with the same key, you get the original response back —
the prompt is not created twice. Sending that same key with a different
text or a different topic_id fails with conflict instead of quietly
overwriting what the key already meant.