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.
Adding the connector
Section titled “Adding the connector”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:
claude mcp add --transport http brand-portal https://mcp.drawn.guide/mcpThe 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.
Who can do what
Section titled “Who can do what”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.
| Role | Can |
|---|---|
| Platform admin | Read, edit, publish, and create in every site; the only role that can delete_site |
| Platform member | Read, 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.
Tool catalog
Section titled “Tool catalog”| Tool | What it does | Who | Rate limit |
|---|---|---|---|
list_sites | List sites you have access to | admin + member | — |
get_site | Full detail for one site: provisioning/deploy status, URLs, failedStep, plus branch state (savedHead, mainHead, hasDraft, lastModified, lastCommitter) | admin + member | — |
get_site_content | Read 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_index | Read only the index (page/section ordering + per-section meta) + version — the small, always-client-safe structural read | admin + member | — |
get_site_version | Branch 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 landed | admin + member | — |
get_page_content | Read one page’s record + only that page’s section bodies (in page order) + version; page is its id or slug | admin + member | — |
get_section | Read one section’s full JSON | admin + member | — |
get_section_schema | The live catalog of section types, fields, and authoring notes (JSON Schema) | any authenticated user | — |
get_authoring_guide | The full authoring playbook (research → structure → conventions → build order) — same content as the populate_site prompt, fetchable by the agent itself | any authenticated user | — |
get_onboarding_guide | The 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 page | any authenticated user | — |
get_build_status | The 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 forever | admin + member | — |
list_audiences | The site’s viewer audiences — the valid slugs for section/page access arrays, plus displayName, hasPassword, isDefault | admin + member | — |
list_media | The 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-reports | admin + member | — |
list_installations | GitHub App installations (orgs) you can provision into | admin + member | — |
create_site | Provision a new client site in the background | admin, or member with can_provision | 10/hour |
delete_site | Tear down a site (Cloudflare DNS, Netlify, Supabase); requires re-typing the site name | admin only | 5/hour |
save_sections | Batch-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 colliding | admin + member | 120/hour |
upload_media | Add 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 + member | 60/hour |
upload_document | Add 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 credentials | admin + member | 30/hour |
update_media | Change an existing image’s alt text (manifest-only commit; section content untouched) | admin + member | 60/hour |
delete_media | Remove 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 unused | admin + member | 30/hour |
validate_site | Dry-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 from | admin + member | — |
compare_branches | Diff 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 accurate | admin + member | — |
publish_site | Promote 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 sees | admin + member | 20/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.
Editing an existing site
Section titled “Editing an existing site”The edit loop is read → change → save, with optimistic concurrency:
- A read returns the content plus a
version(thesavedbranch head). - Make your changes and call
save_sectionswith thatversionasbaseVersion. New sections, structural changes (indexOps), site-config changes, and deletions all go through this one tool. It returns a freshversionfor your next write. - If someone else (a human in
/edit, or another chat) wrote in between, you get a[conflict]error — re-readget_site_content, reapply your change on top, and save again. Nothing was written. validate_siteany time for a structurederrors/warnings/orphanFilesreport;publish_sitewhen 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: nullmeans the start of the page.insertAt— the same, positioned by{ pageId, position }(position === order.lengthappends).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 sectionsliveat go-time.
Pages:
addPage—{ id, title, slug?, showInNav?, access?, navGroupId? }; the slug defaults to a unique one derived from the title, andnavGroupIdcreates 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? }; listingpageIdsmoves 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: nullmoves 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:
- 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. - A failure found only by validating the finished index (a duplicate slug, say) is a
[validation]error reporting the violation without an op index. - 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-livesetMetaover 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.
Retrying a write that may have landed
Section titled “Retrying a write that may have landed”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.
Site-local section types
Section titled “Site-local section types”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 currentversion?” or “did my timed-out write land?”.get_site_index— page/section ordering and per-section meta, nothing else. Pair it with a full-indexsave_sectionswrite, 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. Itsmissingarray lists ordered ids whose body couldn’t be loaded; if it’s non-empty, that page view is incomplete.get_site_contentwithscope/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
sectionsitems are{ id, content, meta }, wherecontentis the full section object —{ "type": ..., "content": {...}, "options": {...} }. Editcontentand post the item straight back insave_sections’sectionsarray (the extrametakey is ignored). The same full-object shape is whatget_sectionreturns and what you author for new sections. - Its
indexandsiteConfigare directly postable back assave_sections’indexandsiteConfig. (siteIndexis accepted as an alias forindex, matching the editor’s/api/savekey.) - To create a section, pass the updated
index(new id insections{}and in a page’sorder) together with the section body insections, 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 itsportal.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.
Creating a site
Section titled “Creating a site”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.
Populating a blank site
Section titled “Populating a blank site”Once a site exists (via create_site or already provisioned), ask Claude to populate it:
Use the
populate_siteprompt for site<siteId>. Source material:~/Downloads/brand-kit/andhttps://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_site → publish_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.
Onboarding a client
Section titled “Onboarding a client”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.
| Argument | Description |
|---|---|
siteId | Portal site id (see list_sites). |
context | Intake 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.
Troubleshooting
Section titled “Troubleshooting”access_deniedduring login — your Google account has noplatform_usersrow and matches noallowed_signupspattern. 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]onsave_sections— yourbaseVersionis stale. Re-read, reapply, retry. A common cause:publish_sitedeletes thesavedbranch, so any pre-publishbaseVersionconflicts — usenull(or re-read) after publishing. Another: your own previous attempt timed out after committing, which moved the head. Send anidempotencyKey(above) and a retry that already landed reports itself instead of colliding. A third, specific toindexOps: your ops referenced ids that didn’t exist at yourbaseVersion(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_versionwith that call’sidempotencyKey. A namedidempotency.commitmeans it landed, so there is nothing to redo. Otherwise retry the identical call with the same key (a landed write reportsalreadyApplied), or re-read and compareversionagainst the one you started from. Note thatcommit: nullmeans 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. HonourRetry-Afterand 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_schemahas 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 pollingget_site; if it lands onfailed, thefailedStepvalue plusdelete_sitecleanup is the recovery path.