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.

ToolScopeCall shapeWhat it does that needs approving
delete_promptsprompt:writeitems: [{ 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_topicsprompt:writeitems: [{ topic_id }]Every prompt on the topic moves to general; detached counts them (archived and deleted ones included).
delete_tagsprompt:writeitems: [{ tag_id }]The tag comes off every prompt that carried it. Which prompts those were is not recoverable.
delete_tag_groupsprompt:writeitems: [{ group, delete_tags? }]Ungroups the group's tags, or deletes them too with delete_tags: true; tags_affected counts them.
remove_competitorsbrand:writeitems: [{ brand_id }]Stops tracking a competitor. Your own brand is refused.
delete_source_classificationbrand:writeitems: [{ kind, name }]Deletes a custom class and clears every override pinned to it; overrides_cleared counts them, measured before the delete.
unassign_source_classificationbrand:writeitems: [{ kind, target }]Drops one target's pinned class so it falls back to automatic classification.
finalize_prompt_candidatesprompt:write{ candidate_ids[], decision }Accepts candidates into the workspace (which spends prompt slots and, on a trial, lifetime ones) or rejects them.
cancel_runrun: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_accountmember:manage{ target_workspace_id, label, scopes[] }Mints a new API key. The key is returned once and never again.
revoke_mcp_connectionmember:manage{ key_id }Kills a key immediately. Anything using it stops working.
invite_membermember:manage{ workos_user_id, role, workspace_allowlist? }Adds a person to the organization and takes a seat.
update_member_rolemember:manage{ user_id, role?, workspace_allowlist? }Changes what a person may do.
revoke_member_accessmember: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

CodeWhat happenedWhat to do
approval_requiredNo approval_token on a call that needs one.Call request_approval with these exact arguments.
approval_invalidThe 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_expiredMore than ten minutes since it was minted. detail.expires_at says when it lapsed.Mint a new one and redeem it promptly.
approval_replayedThat 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

ToolScopeItem shapeStatuses
add_competitorsbrand:write{ name, domains[] (1–10, bare hostnames), aliases[]?, regex?, color? }created / updated / rejected (self_brand)
update_brandsbrand:write{ brand_id, name?, domains[]?, aliases[]?, regex?, color? } (at least one)updated / rejected (not_found)
import_promptsprompt: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_promptsprompt:write{ prompt_id }updated / skipped (already archived) / rejected (not_found)
unarchive_promptsprompt: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_promptsprompt:write{ prompt_id, topic_id?, tag_ids[]? } (at least one)updated / rejected (not_found, unknown_topic, unknown_tag)
update_prompt_candidatesprompt:write{ candidate_id, text?, system_tags[]? } (at least one)updated / rejected (not_proposed, not_found)
create_topicsprompt:write{ name, slug?, country_code? }created / updated (slug already exists) / rejected (market_scope_required, location_conflict)
update_topicsprompt:write{ topic_id (uuid — the row's own id, not the slug), name }updated / rejected (not_found, market_scope_required, location_conflict)
create_tagsprompt:write{ name, color?, group? }created / updated (name already exists) / rejected (system_tag)
update_tagsprompt:write{ tag_id, name?, color?, group? } (at least one)updated / rejected (not_found_or_system, system_tag, name_taken)
update_tag_groupsprompt:write{ group, name?, color? } (at least one)updated / skipped (0 rows touched)
create_source_classificationbrand:write{ kind (domain or url), name, color? }created / updated (name already exists in that kind)
assign_source_classificationbrand:write{ kind, target, classification }updated / rejected (unknown_classification)

Two rows are not patches in the way the rest are:

  • add_competitors is full-replace on name/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, not https://acme.com/ — since that derivation is what keeps a re-add from duplicating a row. name and domains are always replaced with whatever you send. aliases is also unconditional — re-adding a tracked domain without aliases resets it to []. regex and color, 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 with self_brand rather than quietly turning your brand into a competitor — edit it with update_brands.
  • update_brands is 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 updated even if it is archived; unarchive it with unarchive_prompts or it will not run.
  • tag_ids must name your own tags. import_prompts and update_prompts reject an id that names no tag with unknown_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_tags likewise rejects a rename onto another of your tags with name_taken — tag names are unique.
  • Two tools hand back an id you supplied, not a row id. assign_source_classification returns the target you sent (an override has no row id of its own) and update_tag_groups returns the group name. Those values are part of the response stored under an idempotency_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.

ToolScopeShapeResult
set_weekly_scheduleprompt: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_watchlistprompt:writeitems: [{ 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_watchlistprompt:writeitems: [{ prompt_id }]updated / skipped (not on it) / rejected (not_found)
tag_promptsprompt:writeitems: [{ prompt_id, tag_ids (1–20) }]updated / rejected (not_found, unknown_tag)
deduplicate_promptsprompt: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_topicsprompt:write{ country_code? }one row per theme: topic_id, name, status (created or exists); refuses no_profile, market_scope_required
update_brand_profilebrand: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_workspaceworkspace: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.

ToolScopeShapeResult
update_action_itemreport: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.

ToolScopeShapeResult
allocate_workspace_capacityworkspace: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.

ToolScopeShapeRefusals
run_question_nowrun:execute{ workspace_id, prompt_ids (1–50), engines?, max_quota_impact? }market_scope_required, unknown_prompt, estimate_exceeds_max, organization_capacity_exhausted
retry_failed_executionsrun:execute{ workspace_id, run_id, max_quota_impact? }run_not_found, nothing_to_retry, retry_too_large, estimate_exceeds_max, organization_capacity_exhausted
onboard_brandbrand: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_questionsprompt: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, or null on a dry run
  • status — queued, or not_started on a dry run
  • dimension — which kind of work this is (ondemand, onboarding, regen)
  • prompts, engines, asks, estimated_usd — the estimate, the same numbers estimate_run_cost reports for the same arguments
  • quota_dimension, quota_used, quota_limit — the metered pool this request draws on, or null for 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.

ToolScopeShapeResult
set_market_profileworkspace: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_changeworkspace: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_changeworkspace: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_changeworkspace:write{ workspace_id? }{ canceled: boolean } — idempotent, false when nothing was pending
list_market_locationsworkspace: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_locationworkspace: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_locationworkspace: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_locationworkspace: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.

ToolTierScopeShapeSpendsRefusals
discover_keywords3prompt: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_volumes3prompt:write{ workspace_id, phrases (1–1000), market?, limit?, cursor? }≤ 1 credit; cache hits freesame, minus estimate_exceeds_max
expand_topics3prompt: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_status1prompt:read{ workspace_id, run_id, intents?, limit?, cursor? }nothingnot_found
link_prompt_keyword2prompt:write{ workspace_id, prompt_id, group_id | null }nothingnot_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.

← All docs