Skip to content

Using the MCP Connector

The Brand Portal MCP connector (mcp.drawn.guide) lets any authorized Drawn Agency teammate create, edit, and populate client portal sites directly from Claude — no local checkout, no per-site setup. It is a single hosted service shared by the whole org; access is scoped per user by the same platform roles as the admin app. For how the service itself is built and deployed, see MCP connector internals.

The connector is added once, org-wide, in Claude’s connector settings. If it’s not yet listed for your account, ask an admin to add it (Settings → Connectors → Add connector → https://mcp.drawn.guide/mcp).

In Claude Code, add it per machine instead:

Terminal window
claude mcp add --transport http brand-portal https://mcp.drawn.guide/mcp

The first time you use it, you’ll complete an OAuth login with the same Google account you use for the portal admin app, then approve a one-time consent screen. Access is gated by the same platform_users/allowed_signups rules as admin.drawn.guide — if your account isn’t recognized, access is denied and no token is issued; ask an admin to add you.

Only Claude clients can connect: the connector’s OAuth registration allow-list accepts Claude.ai’s callback and localhost loopback (Claude Code) redirect URIs, nothing else. ChatGPT and other MCP clients cannot complete the login flow today.

Your capability is your platform role, re-checked from the database on every call — a change takes effect on your next tool call, not your next login.

RoleCan
Platform adminRead, edit, publish, and create in every site; the only role that can delete_site
Platform memberRead, edit, and publish sites belonging to GitHub installations they’re a member of; create_site only if an admin has enabled can_provision for them

Site-scoped tools answer identically for “site doesn’t exist” and “you don’t have access” — if you get “Site not found or you do not have access to it” for a site you believe exists, ask an admin about your installation membership.

ToolWhat it doesWhoRate limit
list_sitesList sites you have access toadmin + member
get_siteFull detail for one site: provisioning/deploy status, URLs, failedStep, plus branch state (savedHead, mainHead, hasDraft, lastModified, lastCommitter)admin + member
get_site_contentRead a site’s sections/index/config (saved draft by default, falls back to main) + the version used for writes. Narrow it with scope (full|index|siteConfig) and bodies (full|truncated|none)admin + member
get_site_indexRead only the index (page/section ordering + per-section meta) + version — the small, always-client-safe structural readadmin + member
get_site_versionBranch heads only (version, mainHead, hasDraft) — the cheapest read, for recovering after a write whose result you never saw. Pass the idempotencyKey of a timed-out save_sections to ask whether it landedadmin + member
get_page_contentRead one page’s record + only that page’s section bodies (in page order) + version; page is its id or slugadmin + member
get_sectionRead one section’s full JSONadmin + member
get_section_schemaThe live catalog of section types, fields, and authoring notes (JSON Schema)any authenticated user
get_authoring_guideThe full authoring playbook (research → structure → conventions → build order) — same content as the populate_site prompt, fetchable by the agent itselfany authenticated user
get_onboarding_guideThe client onboarding process guide (three-page model, conventions, page plan, scaffold + evolve workflow) — same content as the brand_onboarding prompt, fetchable by the agent before scaffolding or editing a Brand Foundation pageany authenticated user
get_build_statusThe site’s latest Netlify deploy state (state, deployUrl, commitSha, updatedAt) — for confirming a publish_site actually built. source is "netlify" for a live, authoritative read (brokered through admin, which holds the Netlify credential) or "stored" for the webhook-fed record; a stored building older than 15 minutes reports as state: "unknown" with staleBuilding: true rather than spinning foreveradmin + member
list_audiencesThe site’s viewer audiences — the valid slugs for section/page access arrays, plus displayName, hasPassword, isDefaultadmin + member
list_mediaThe site’s media manifest — imageIds to reference in section media fields. Narrow with query/kind/limit, add usedBy with includeUsage (the same raw section-file scan delete_media’s guard uses), or pass versionOnly: true for just the version. Documents come back alongside the images, as a parallel documents array (documentId, filename, size, kind, storage, usedBy) plus documentsTotal; query and limit apply to both lists, kind narrows images only. A warnings key appears when an indexed section file isn’t valid JSON, meaning usedBy under-reportsadmin + member
list_installationsGitHub App installations (orgs) you can provision intoadmin + member
create_siteProvision a new client site in the backgroundadmin, or member with can_provision10/hour
delete_siteTear down a site (Cloudflare DNS, Netlify, Supabase); requires re-typing the site nameadmin only5/hour
save_sectionsBatch-write sections, index changes, and/or site config to the saved draft, in one transactional commit; indexOps applies structural deltas server-side (no index transcription), pruneOrphans: true also deletes section files the resulting index doesn’t reference, and idempotencyKey lets a retry after a timeout report itself instead of collidingadmin + member120/hour
upload_mediaAdd an image by public url (preferred) or base64 (≤5 MiB, JPEG/PNG/WebP) — optimized server-side into WebP variants, returns an imageId (+ deduplicated: true if it already existed)admin + member60/hour
upload_documentAdd a PDF or self-contained HTML document by public url (preferred, up to 100 MB), raw html source, or base64 PDF bytes (≤2.5 MB) — returns the documentId a document section carries at content.doc.documentId (never an imageId), plus deduplicated: true on identical bytes. Files over 2.5 MB go to the site’s documents bucket and need both that store and the connector’s R2 credentialsadmin + member30/hour
update_mediaChange an existing image’s alt text (manifest-only commit; section content untouched)admin + member60/hour
delete_mediaRemove an image from the manifest and delete its files. Refuses when a section file still references it unless force: true. Always refuses — force included — in two cases: the site config (logo / social preview) references it, or an indexed section file on the branch isn’t valid JSON, since an image that file uses would scan as unusedadmin + member30/hour
validate_siteDry-run validation of a branch’s content, without writing anything — returns errors, warnings, and orphanFiles. Defaults to the saved draft when one exists, otherwise main — the same branch pruneOrphans would delete fromadmin + member
compare_branchesDiff the saved draft against published main — returns changedSectionIds, indexChanged, siteConfigChanged, files, and hasDraft (false when no draft exists). GitHub caps the file list at 300; when filesTruncated: true those file-derived fields are partial (media variants sort ahead of section files), so re-read rather than trusting a cache — differs/aheadBy/behindBy stay accurateadmin + member
publish_sitePromote the saved draft to main, triggering the site’s Netlify rebuild; returns { published: true, sha, nextBaseVersion: null }, or a non-error { published: false, reason: "no_draft", … } when there was nothing to promote — which is what a retry after a lost response seesadmin + member20/hour

All writes target the saved draft — nothing is public until publish_site. Preview a draft by logging into the site’s own /edit (it reads saved). delete_site does not delete the GitHub repo or its media; that remains a manual step.

The edit loop is read → change → save, with optimistic concurrency:

  1. A read returns the content plus a version (the saved branch head).
  2. Make your changes and call save_sections with that version as baseVersion. New sections, structural changes (indexOps), site-config changes, and deletions all go through this one tool. It returns a fresh version for your next write.
  3. If someone else (a human in /edit, or another chat) wrote in between, you get a [conflict] error — re-read get_site_content, reapply your change on top, and save again. Nothing was written.
  4. validate_site any time for a structured errors/warnings/orphanFiles report; publish_site when the draft is ready to go live.

Change structure with indexOps, not by posting the index

Section titled “Change structure with indexOps, not by posting the index”

A real site’s index is 40–51 KB. Copying it into a tool call to add one section is the single most common way an agent’s write fails — at that size a transcription slip is near-certain, and the partition validator rightly rejects the result. indexOps sends the change instead — twelve ops, covering all three levels of a site’s structure.

Sections:

  • insertAfter{ pageId, afterId, sections: [{ id, status?, access? }] }; afterId: null means the start of the page.
  • insertAt — the same, positioned by { pageId, position } (position === order.length appends).
  • move{ pageId, id, afterId }; relocates an existing section, within or across pages.
  • remove{ ids }; takes the sections out of the index and deletes their content files.
  • setMeta{ id, status?, access? }; the way to flip a batch of sections live at go-time.

Pages:

  • addPage{ id, title, slug?, showInNav?, access?, navGroupId? }; the slug defaults to a unique one derived from the title, and navGroupId creates the page straight inside an existing sidebar group.
  • removePage{ pageId, removeSections? }.
  • setPageMeta{ pageId, title?, slug?, showInNav?, access?, status? }.

Page groups (the sidebar nav sidecar), all addressed by id so you never transmit the tree:

  • addNavGroup{ id, label?, startCollapsed?, pageIds? }; listing pageIds moves those pages in, in that order.
  • removeNavGroup{ groupId }; removes the grouping only. Its member pages survive as top-level sidebar entries.
  • setNavGroupMeta{ groupId, label?, startCollapsed? }.
  • movePageInNav{ pageId, groupId, position? }; groupId: null moves the page out to the top level.

The server applies the ops in array order against the index at your baseVersion, validates the whole result, and commits — a rejected op writes nothing. Three outcomes are worth telling apart:

  1. An op that fails on its own terms (unknown page, id already present, no body posted) is a [validation] error naming the op index and the offending id.
  2. A failure found only by validating the finished index (a duplicate slug, say) is a [validation] error reporting the violation without an op index.
  3. If the ops don’t apply, the server re-reads the saved head before blaming them. If the draft has moved since your baseVersion, you get a [conflict] instead — the ops may well be correct against the current index, so re-read and compare rather than editing them. This is the common retry shape, not an exotic one: a go-live setMeta over sections a previous call just created names ids that do not exist at the pre-create commit.

Creating sections takes one call: the insert op plus those sections’ bodies in sections. An inserted section’s type comes from its body (so post it in the same call), and its status defaults to draft.

indexOps is mutually exclusive with index/siteIndex, and requires baseVersion to be a commit sha or null. Posting a full index still works, and remains the way to repair a broken index or replace structure wholesale.

The page lifecycle is fully covered, with guards worth knowing. removePage refuses a page that still holds sections unless you pass removeSections: true — which deletes them and their content files along with the page, so move anything worth keeping elsewhere first. The home page can be neither removed nor archived, and its slug can’t be set (promote a different page to home in /edit first). To hide a page without deleting anything, use setPageMeta with status: "archived": the page and its sections drop out of the viewer while the content stays on the branch. A full index is now needed only to repair a broken index, or to change which page is home.

For the full semantics of the page-group ops — and the guarantee that section-level ops emit no nav at all, so ordinary content writes cannot disturb grouping — see the nav.json reference.

save_sections takes an optional idempotencyKey: any opaque string of 8–200 characters from A-Za-z0-9._:-, unique per distinct write.

It exists for one situation. When a write times out, you cannot tell whether the commit landed — and because every write is concurrency-checked against baseVersion, a blind retry of a write that did land comes back as [conflict], which reads like failure. Nothing is ever double-committed either way (the original commit moved the head, so the retry’s baseVersion is stale by definition), but “conflict” doesn’t tell you that.

Pass a key and the retry can be recognised. Retry the call with the same key: if the original landed, the response is a success carrying alreadyApplied: { commit } and a note saying nothing was written again. version is still the current draft head, so it stays usable as your next baseVersion — if another writer landed something after you, it won’t equal alreadyApplied.commit, and the note says to re-read before editing further.

A key is honoured at most once, checked both before the write and again if the write conflicts. So the retry is safe whether or not you refreshed baseVersion first — which matters, because refreshing it (say via list_media with versionOnly) means the retry doesn’t conflict, and without the at-most-once check the ops would simply apply a second time and hand you a duplicate section reported as an ordinary success.

Two limits worth knowing:

  • Generate a fresh key for each distinct write. Reusing one for different content reports the old commit as applied and the new content is silently not written.
  • A “key not found” answer is not proof your earlier attempt failed. The lookup searches a bounded window of recent draft commits and answers “not found” if the history read itself fails. On a [conflict] that says the key wasn’t found, re-read and compare before reapplying — don’t assume the earlier write is gone.

A section type defined in the client repo’s own src/sections.ts is invisible to the connector, which can only load the framework’s built-in schemas. Passing such a section fails with [validation] unknown section type, naming the value to allow. Pass it in allowUnknownTypes on that first save and the section is committed unvalidated, with a warning. Once a section of that type exists on the branch it’s trusted automatically, so this is only ever needed once per type.

Read the smallest thing that answers the question. get_site_content’s default read returns every section body at once, which can overflow the client’s tool-result limit on a real-size site and kill the session. Four narrower reads exist:

  • get_site_version — branch heads only, no content at all. The floor: use it when the question is just “what’s the current version?” or “did my timed-out write land?”.
  • get_site_index — page/section ordering and per-section meta, nothing else. Pair it with a full-index save_sections write, or use it to decide what to read next.
  • get_page_content — one page’s record plus only that page’s section bodies, in page order. The right read for a “work on one page” session. Its missing array lists ordered ids whose body couldn’t be loaded; if it’s non-empty, that page view is incomplete.
  • get_site_content with scope / bodies — e.g. bodies: "truncated" for an {id, meta, bytes, preview} inventory of every section, to find the one you want by its text.

All of them return a version for the same branch; the content reads share one commit, and the scoped ones just return less. They do not reduce server-side work, only response size — get_site_version is the exception, since it reads no content.

The read/write contract is round-trippable. What the reads return is what save_sections accepts:

  • Its sections items are { id, content, meta }, where content is the full section object{ "type": ..., "content": {...}, "options": {...} }. Edit content and post the item straight back in save_sectionssections array (the extra meta key is ignored). The same full-object shape is what get_section returns and what you author for new sections.
  • Its index and siteConfig are directly postable back as save_sectionsindex and siteConfig. (siteIndex is accepted as an alias for index, matching the editor’s /api/save key.)
  • To create a section, pass the updated index (new id in sections{} and in a page’s order) together with the section body in sections, in the same call — validation checks sections against the index you’re posting, not the stored one.

The one part of the index that is not safely round-trippable: nav. Page grouping and sidebar order live in a sidecar file, src/content/nav.json, never inside index.json — reads attach the tree to the index as a nav key purely as a wire convenience, and the write path lifts it back off. Writes to it are wholesale replacements, and the two directions are asymmetric: omit nav from a posted index and the file is left untouched; include it and it is replaced entirely — so nav: [] parses cleanly and deletes every page group on the site, with no error and no warning. Either post back exactly the nav you read, or leave the key out. Better still, change grouping with indexOps (addNavGroup / removeNavGroup / setNavGroupMeta / movePageInNav, plus addPage’s navGroupId): section-level ops emit no nav at all, and the page/nav ops write a tree already reconciled against the resulting pages. Full detail: nav.json reference.

A successful save may include warnings — non-blocking issues such as HTML entities (’, &, …) in plain-text fields. Only rich-text fields (e.g. prose.body) are HTML; everywhere else entities render literally to viewers, so replace them with the actual character and re-save.

Orphaned section files. validate_site reports orphanFiles next to errors and warnings: section files that exist on the branch but sit in no page order — usually left behind when a section was dropped from the index without being deleted. They’re invisible to viewers and to every read tool, but they stay in the repo. To clear them, re-save with pruneOrphans: true: save_sections diffs the index you’re posting against the branch’s section files and folds the difference into the delete set, naming every id it removed in the response warnings. That is a real delete, so read orphanFiles first — anything worth keeping should be added back to a page order instead of pruned. An index the connector can’t read (or one you’re posting without pages) prunes nothing, so a malformed payload can’t wipe a site’s sections.

A section file that isn’t valid JSON (a truncated write, a hand-edit) no longer fails the whole read. The site loads without that section’s body, and the id stays in the index — the file is repairable in place, and dropping the reference would turn one broken section into an orphan. validate_site names the file (src/content/sections/<id>.json … is not valid JSON), get_page_content and get_site_content list the id under missing, and get_section says so rather than answering “not found”. A non-empty missing means that read is INCOMPLETE, not that those sections are gone — their files are still on the branch. Never post back an index that drops those ids, and never pruneOrphans off such a read, or the next write turns a repairable file into an orphan and then deletes it. The fix is to re-save the section with a valid body — that overwrites the file. Until then media is untrustworthy for that branch: the image-reference scan can’t read a malformed file, so list_media’s usedBy under-reports (it says so in warnings) and delete_media refuses outright, force included.

Images must be uploaded before the sections that reference them: upload_media returns the imageId you place in section content. Re-uploading the same image is a no-op — the response carries deduplicated: true and the existing imageId (uploads are deduplicated by content hash); if you pass a differing alt on a dedup hit, it is applied to the existing image. Local image files can’t be sent through upload_media (base64 bodies overflow chat tool limits): add them via the site’s /edit media library, then reference them with list_media.

Documents follow the same upload-first rule, in their own manifest. upload_document takes a PDF or a self-contained HTML file and returns a documentId — the value a document section carries at content.doc.documentId. It is never an imageId, and the two manifests never mix: list_media returns documents as a parallel documents array (with usedBy under includeUsage, same as images). So the loop is upload, then save the section body — one indexOps insert plus { "type": "document", "content": { "title": …, "doc": { "documentId": … }, "showInDocumentsNav": … } } in the same save_sections call. Leave title empty to show the file name without its extension. url is the transport to reach for; html lets you write markup directly (no base64 inflation), and base64 is for a genuinely tiny PDF only — file bytes encoded into a tool call overflow the chat’s limits, so a local file goes through the site’s /edit media library instead. Identical bytes re-uploaded return the existing documentId with deduplicated: true, applying the new filename to it.

The 2.5 MB tier boundary, and what it needs. Up to 2.5 MB a document is committed into the repository like section content and works on any site. Above that it is stored in the private documents bucket instead, which requires two separate things to be true — and each failure has its own [input] message rather than a silent success:

  • the connector deployment must have its R2 documents credentials set (absent ⇒ the connector runs git-tier-only and tells you the file has to go through /edit); and
  • the client site must configure a documents store (documents: r2Documents() in its portal.config.mjs). Without it the site’s own gated file route answers 502 for every bucket-tier document, so an upload would report success and leave viewers a broken row. The connector checks the site’s config before storing anything.

Only url can exceed 2.5 MB (base64 and html are capped there), and the connector’s own ceiling is 100 MB — larger files go through /edit.

Fixing a bad upload. update_media rewrites an image’s alt text without touching sections. delete_media removes the image and its files — it first scans every section file on the branch (including site-local section types the connector can’t schema-check, and files no page order references) plus the site config, and refuses when anything still points at the imageId. A section reference can be overridden with force: true, which leaves those sections rendering a broken slot until you fix them. A site-config reference — the site logo or the social-preview image — can’t be overridden at all. That’s a severity call, not a capability one: save_sections can write siteConfig, so the fix is two calls — clear the slot (save_sections with a siteConfig whose logo/previewImage no longer names that imageId, or change it in the site’s /edit settings), then delete_media. Making that an explicit separate step is the point: no single call should be able to take out a live site’s logo.

One caveat on reads: version is a usable baseVersion only from the default branch: "saved" read. A branch: "main" read returns main’s head, which always conflicts as a baseVersion — re-read with branch: "saved" before writing.

Checking where you stand. get_site reports branch state without a content read: savedHead is the saved-draft head (also a valid save_sections baseVersion), mainHead is the published commit, hasDraft says whether unpublished work exists, and lastModified / lastCommitter describe the newest commit on whichever head is current (the draft when there is one, else main). On a site that hasn’t finished provisioning, all of them come back null/false rather than erroring — check provisioningStatus there.

Publishing resets the draft. publish_site promotes saved to main and deletes the saved branch — so it returns { published: true, sha, nextBaseVersion: null }, and that nextBaseVersion is exactly what the next save_sections must pass (a fresh get_site_content will likewise return version: null). Reusing a pre-publish version causes a [conflict].

A timed-out publish_site usually succeeded. Promotion is a handful of GitHub calls and finishes in seconds; a client-side timeout is far more often a lost response than a failed publish. Retrying is safe rather than destructive — with the draft already gone the retry has nothing to promote, and answers, as a normal (non-error) result:

{ "published": false, "reason": "no_draft", "mainHead": "",
"mainHeadIsPublishCommit": true, "mainHeadCommittedAt": "", "nextBaseVersion": null }

mainHeadIsPublishCommit: true means main’s head is a portal publish commit — check mainHeadCommittedAt against when you called. (null there means the head could not be read, not that it isn’t one.) The one thing never to do is treat an ambiguous publish as a failure and re-save with the pre-publish baseVersion. There is no idempotencyKey on publish_site and there cannot be one built the way save_sections’ is: that key lives on a commit on the draft branch, which publishing deletes.

create_site takes a site name, GitHub org, and repo name (plus optional subdomain, defaulting to a slug of the name under drawn.guide). It returns immediately with a siteId and provisioningStatus: "pending" — provisioning runs in the background. Poll get_site until the status is complete (site live at https://{subdomain}.drawn.guide) or failed, in which case failedStep says which step broke. A half-provisioned site can be cleaned up with delete_site (admin). If the org you want isn’t in list_installations, the Portal GitHub App isn’t installed there yet.

Once a site exists (via create_site or already provisioned), ask Claude to populate it:

Use the populate_site prompt for site <siteId>. Source material: ~/Downloads/brand-kit/ and https://example-brand.com. Context: craft brewery in Toronto, rustic industrial aesthetic.

Claude reads the local files and fetches the URLs itself, plans the site’s structure, then drives get_section_schema (once, to load the current catalog of section types and fields) → upload_media (once per image, to get back an imageId) → upload_document (once per PDF or HTML file, for a documentId) → save_sections (referencing those imageIds and documentIds in the section content) → validate_sitepublish_site to build it — pausing to report what it built and asking for your approval before publishing. Images and documents have to be uploaded before the sections that reference them are saved, since you need the imageId or documentId before you can put it in the section content. The research and authoring intelligence runs in your own Claude session; the connector only supplies the deterministic primitives (schema catalog, content writes, image optimization, validation, publish).

populate_site vs get_authoring_guide vs /populate-site: three entry points to the same authoring workflow. The populate_site MCP prompt (this page) works from chat against a remote site — no checkout needed — but must be invoked by you from the connector’s prompt menu; the agent cannot pull it in itself. The get_authoring_guide tool carries the same playbook and IS agent-invocable — if you just ask Claude to “populate the site” in plain language, the connector’s boot instructions steer it to fetch the guide before authoring. The /populate-site Claude Code skill runs the same guidance locally inside a checked-out client repo, using the authoring CLI. The narrative guidance is single-sourced in @drawnagency/authoring, so all three stay in step.

The brand_onboarding prompt runs a client onboarding session against a remote site — scaffolding a client’s Brand Foundation page from intake materials, or evolving an existing one with new material (transcripts, notes, assets). It checks the site’s pages first: no Brand Foundation page yet means scaffold; one already there means evolve.

ArgumentDescription
siteIdPortal site id (see list_sites).
contextIntake material summary, or the new transcript/notes for an existing page.

The agent can also pull the same guidance itself with the get_onboarding_guide tool — the three-page model, conventions, page plan, and the scaffold + evolve workflow. Both share one source (embedded at connector build time from @drawnagency/authoring), and the local brand-onboarding Claude Code skill runs the same process inside a checked-out client repo. See Brand onboarding for the full model.

  • access_denied during login — your Google account has no platform_users row and matches no allowed_signups pattern. Ask an admin to add you.
  • [authz] errors — not retryable; you lack the role or membership for that tool (see Who can do what).
  • [conflict] on save_sections — your baseVersion is stale. Re-read, reapply, retry. A common cause: publish_site deletes the saved branch, so any pre-publish baseVersion conflicts — use null (or re-read) after publishing. Another: your own previous attempt timed out after committing, which moved the head. Send an idempotencyKey (above) and a retry that already landed reports itself instead of colliding. A third, specific to indexOps: your ops referenced ids that didn’t exist at your baseVersion (typically ids the stale-making commit created) — the ops are probably fine, so re-read and retry them rather than rewriting them.
  • A write timed out and you don’t know if it landed — don’t re-send blind. Cheapest first: call get_site_version with that call’s idempotencyKey. A named idempotency.commit means it landed, so there is nothing to redo. Otherwise retry the identical call with the same key (a landed write reports alreadyApplied), or re-read and compare version against the one you started from. Note that commit: null means not confirmed, not “it failed”: the lookup searches a bounded window of recent draft history and answers null if the history read itself fails, so compare content before reapplying.
  • 401 invalid_token — the message says which case it is. Token expired is the ordinary one and the only client-recoverable one: access tokens live an hour, so a long authoring session can still cross it, and the client should refresh rather than treat the connection as dead. A client that marks the connection failed here will not recover in place — start a new session. The other cases (retired key, wrong signature, not issued for this server, malformed, access revoked) need the connector reconnected.
  • 503 temporarily_unavailable — the authorization backend blipped; the token is fine. Honour Retry-After and retry. This is deliberately not a 401: an auth error would make the client throw away a valid token, and a bare 500 would look like a broken server.
  • [validation] errors — the payload failed schema validation; the error’s second line lists the failing fields and why. get_section_schema has the authoritative field shapes.
  • Rate-limit errors — per-user, per-tool hourly windows (table above). They come back as [unavailable], not [validation]: the payload was fine, nothing was written, and the message names the seconds to wait. Do not rewrite the call — wait and send the same one.
  • Provisioning stuck on pending — keep polling get_site; if it lands on failed, the failedStep value plus delete_site cleanup is the recovery path.