MCP server

HTMLvault exposes a hosted Model Context Protocol (MCP) server so AI agents can publish and manage secure links directly, with no curl and no glue code. The endpoint is Streamable HTTP at https://htmlvault.com/api/mcp, and every plan can connect. Free accounts sign in over OAuth and get the publish, edit, read, analytics, delete and scan tools within the same limits as the web upload form. Static API keys, batch creation and recipient links need a paid plan (Pro, Teams or Enterprise).

Endpointhttps://htmlvault.com/api/mcp
TransportStreamable HTTP (JSON-RPC 2.0 over a single POST)
AuthOAuth 2.1 sign-in (custom connectors), available on every plan including Free, or an Authorization: Bearer <key> API key, which requires Pro, Teams or Enterprise
Toolslist_links · get_link · get_analytics · create_link · create_draft_link · append_html · create_links · update_link · patch_link · delete_link · scan_html · create_recipient_links
PlansFree: ten of the twelve tools, with publishing capped at 50 links a month and 30-day expiry. Pro, Teams and Enterprise: all twelve, unlimited links and any expiry. Full breakdown in Plans & limits.

Quick start

OAuth-capable clients (claude.ai, Claude Desktop, Claude Code, ChatGPT) need no API key and no paid plan: you sign in with your HTMLvault account when the client prompts you, on Free exactly as on a paid plan. For everything else, generate an API key (Pro, Teams or Enterprise) on the Settings → API keys screen and replace hv_YOUR_KEY in the snippets below.

claude.ai & Claude Desktop (OAuth, recommended)

In Settings → Connectors → Add custom connector, paste the endpoint URL, then click Connect and sign in. No terminal, no config file, no key.

https://htmlvault.com/api/mcp

Claude Code

Run once in your terminal. You’ll be prompted to sign in on first use, and HTMLvault is then available in every Claude Code session. Prefer a static key (e.g. CI)? Append --header "Authorization: Bearer hv_YOUR_KEY".

claude mcp add --transport http htmlvault https://htmlvault.com/api/mcp

Claude Desktop: static-key bridge (advanced)

Only needed if you’d rather authenticate with an API key than OAuth: use the mcp-remote bridge in claude_desktop_config.json:

claude_desktop_config.json

{
  "mcpServers": {
    "htmlvault": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://htmlvault.com/api/mcp",
               "--header", "Authorization: Bearer hv_YOUR_KEY"]
    }
  }
}

Cursor

Add to ~/.cursor/mcp.json (or the project-level .cursor/mcp.json):

~/.cursor/mcp.json

{
  "mcpServers": {
    "htmlvault": {
      "url": "https://htmlvault.com/api/mcp",
      "headers": { "Authorization": "Bearer hv_YOUR_KEY" }
    }
  }
}

Anthropic API (MCP connector)

Pass the endpoint in the mcp_servers array of a Messages API request (requires the MCP connector beta header):

{
  "model": "claude-sonnet-4-6",
  "mcp_servers": [
    {
      "type": "url",
      "url": "https://htmlvault.com/api/mcp",
      "name": "htmlvault",
      "authorization_token": "hv_YOUR_KEY"
    }
  ],
  "messages": [{ "role": "user", "content": "Vault this report and give me the link." }]
}

ChatGPT

ChatGPT (Plus/Pro/Team and up) connects over MCP: enable Developer Mode (Settings → Apps & Connectors → Advanced), add a custom connector pointing at the endpoint above, and sign in when prompted. Alternatively, a Custom GPT Action over the REST API works without Developer Mode. The import-ready OpenAPI schema and step-by-step setup live on the Settings → MCP screen.

Authentication

Every request carries a bearer token in the Authorization header, in one of two modes. OAuth 2.1: connector-style clients discover the authorization server via the RFC 9728 metadata at /.well-known/oauth-protected-resource/api/mcp and run a standard sign-in flow; access tokens are short-lived and refresh automatically. Static API keys: created and revoked on the Settings → API keys screen, shown once at generation time; the right choice for CI and server-side callers.

Authorization: Bearer hv_YOUR_KEY

Personal vs. org keys

OAuth sign-in works on every plan, Free included. Personal API keys require Pro, Teams or Enterprise. Either credential always acts as you, the signed-in user or key owner.

Org keys (Enterprise, created by an admin with api_keys.manage_org) instead act on behalf of the organization owner: every tool call is attributed to the owner’s account. Meant for server-to-server integrations with no logged-in user.

Either way, by default you only see your own (or, for an org key, the owner’s own) links. Pass scope: "all" on list_links, get_link, get_analytics, update_link, patch_link, or delete_link to widen to every org member’s links instead. This requires your role to hold the matching permission (links.view_any, links.edit_any, links.delete_any, or analytics.view_any); omitting scope falls back to your stored preference on Settings → Link defaults (default me). A role without the permission gets an explicit error, never a silent narrowing.

Auth errors

FieldTypeDescription
missing_key401No Authorization header (or empty bearer). Carries a WWW-Authenticate challenge so OAuth-capable clients auto-launch sign-in. Message: “No credentials provided. OAuth-capable MCP clients will prompt you to sign in; otherwise include the header: Authorization: Bearer <key>. Create a key at https://htmlvault.com/connect”
invalid_key401Key is not recognized or has been revoked. Message: “API key not recognized or has been revoked. Manage your keys at https://htmlvault.com/connect”
invalid_token401OAuth access token is expired or invalid. Clients re-authenticate automatically via the WWW-Authenticate challenge.
plan_required403A valid API key on a Free account. Keys need Pro, Teams or Enterprise; the OAuth path never returns this, so a Free account that signs in through a connector is admitted and meets its plan limits per tool instead. Message: “MCP access with an API key requires a paid plan (Pro, Teams or Enterprise). On Free, connect through OAuth sign-in instead.”
upstream_error503A temporary failure on our identity provider's side: its signing keys couldn't be fetched to check the token, or identity resolution failed after the token checked out. Retry the same token after the Retry-After interval.
email_unverified403OAuth token is valid, but the account’s email address is not verified. There is no WWW-Authenticate challenge, because signing in again cannot fix it. The body is a JSON-RPC error: { "jsonrpc": "2.0", "id": null, "error": { "code": -32001, "message": "...", "data": { "reason": "email_unverified" } } }. Verify the address by signing in at https://htmlvault.com with the code we email you, then reconnect.

The 401 cases share a shape ({ "error": "...", "message": "..." }); the key-on-Free case is a distinct 403 so a client can tell “wrong key” from “upgrade needed.”

Plans & limits

The server itself is not gated. A Free account can add the connector, sign in and start publishing; what differs is the credential it can use and which tools answer. OAuth sign-in is available on every plan. Static API keys need Pro, Teams or Enterprise, so a Free account connects through a connector-style client (claude.ai, Claude Desktop, Claude Code, ChatGPT) rather than through a config file with a key in it.

Publishing limits are the same ones the web upload form applies, deliberately: a link created by an agent counts against the same monthly allowance as one created in the browser.

ToolFreePro, Teams & Enterprise
create_linkYes, within 50 links per month and a 30-day expiry ceilingYes, unlimited links, any expiry including never
create_draft_linkYes, and it costs nothing: a draft is charged only when finalizedYes
append_htmlYes. finalize: true is where the 50-per-month allowance is charged and where the 30-day expiry starts counting, so time spent drafting costs nothingYes, unlimited links; a finalized draft takes your account’s default expiry
list_linksYesYes
get_linkYesYes
get_analyticsYes, the same views, engagement and breakdownsYes
delete_linkYesYes
scan_htmlYesYes
update_linkYesYes
patch_linkYesYes
create_linksNo, returns pro_requiredYes
create_recipient_linksNo, returns a plan errorYes

What a Free caller runs into

  • Monthly cap. The 51st create_link in a calendar month returns link_limit_reached, and nothing is created. Append-mode drafts are charged at the same counter, but only on the append_html call that finalizes, so a draft you cannot finalize yet is still there to retry, until its 24-hour window runs out.
  • Expiry ceiling. An expiresIn past 30 days (including never) is capped at 30 days rather than rejected, so the call succeeds with a nearer expiry than asked for. A draft takes no expiresIn at all: the 30 days are counted from create_draft_link, not from the finalize that published it.
  • No custom domain. The domain parameter names an active custom domain, which is a paid feature, so on Free it returns unknown_domain. Links serve from the default share domain.
  • Preview image stored, not shown. An og:image meta tag in the submitted HTML is parsed and stored on any plan, so preview_image echoes back the URL, but preview_image_active is false until the account is on a paid plan. It starts displaying on its own once the plan changes, with no republish needed either way.

Nothing about a Free connection is downgraded silently apart from the expiry cap. Every other limit surfaces as an explicit error naming the plan, so an agent can report it rather than guess. Plan is re-resolved on every request, so an upgrade takes effect on the next tool call, with no reconnect and no new token.

Tool reference

Twelve tools, ten of them available on Free (see Plans & limits). Read-only tools (list_links, get_link, get_analytics, scan_html) are annotated readOnlyHint: true; delete_link is annotated destructiveHint: true. Every tool returns its JSON result inside the MCP content envelope as a text block, and (for clients that read structured output) as structuredContent validated against the tool’s declared outputSchema.

Four of them publish content: create_link (one document), create_links (up to 100 from one template), update_link (replace a document), and patch_link (change parts of a document). Pick the narrowest one that fits: an agent that re-sends a whole document to change a heading, or calls create_link in a loop, pays for every byte twice. A fifth path publishes across several calls rather than one: create_draft_link plus append_html assemble a large document server-side, chunk by chunk, and publish it on the final call.

scan_report

Those four publishing tools accept an optional scan_report parameter controlling how much PII-scan detail comes back on success. It is a response-verbosity switch only: it never changes what is scanned, gated, or blocked.

FieldTypeDescription
scan_report"summary" | "none"Default summary: the success payload carries the non-blocking PII warnings (warnings on create_link / update_link / patch_link, rows_with_warnings on create_links). none omits that key entirely, so an agent that doesn’t act on warnings isn’t charged for reading them.

Under scan_report: "none" the key is absent, not empty. On create_links, a present-and-empty rows_with_warnings means the batch ran and no row warned, while an absent one means reporting was suppressed. A blocked publish always returns its findings regardless of this parameter: scan_report can hide warnings, never the reason a publish was refused. append_html takes no scan_report: a finalize that publishes with non-blocking warnings always returns them.

List your links, newest first, with cursor pagination.

FieldTypeDescription
limitintegerResults per page. Default 20, maximum 100.
cursorstringOpaque pagination token. Pass next_cursor from a previous response to fetch the next page.
created_afterstringISO 8601 UTC timestamp (matches the format in created_at below): return only links created strictly after this time.
created_beforestringISO 8601 UTC timestamp (matches the format in created_at below): return only links created strictly before this time.
scope"me" | "all"Default me (or your stored preference on Settings → Link defaults) lists only your own links. all lists every org member’s links instead (requires your role to hold links.view_any; returns an error otherwise rather than silently narrowing).

Result

{
  "links": [
    {
      "slug": "a1b2c3d4e5f6g7h8",
      "title": "Q3 Proposal",
      "created_at": "2026-06-10T18:22:04.511Z",
      "expires_at": null,
      "view_count": 42,
      "is_public": true,
      "url": "https://htmlvault.io/a1b2c3d4e5f6g7h8"
    }
  ],
  "next_cursor": "MjAyNi0wNi0xMFQxODoyMjowNC41MTFafGExYjJjM2Q0",
  "has_more": true,
  "viewer_timezone": "America/Denver"
}

When has_more is true, call again with cursor set to next_cursor. When it is false, next_cursor is null and you have the last page. All timestamps are UTC; viewer_timezone is the IANA zone from the caller’s account settings (null if unset) so an agent can convert before showing a time to a person.

Fetch one link’s metadata and full stored HTML.

FieldTypeDescription
slugRequiredstringThe link slug.
scope"me" | "all"Default me (or your stored preference) only finds your own links. all also finds any org member’s link (requires links.view_any).

Result

{
  "slug": "a1b2c3d4e5f6g7h8",
  "title": "Q3 Proposal",
  "html_content": "<!doctype html>…",
  "created_at": "2026-06-10T18:22:04.511Z",
  "expires_at": null,
  "view_count": 42,
  "is_public": true,
  "url": "https://htmlvault.io/a1b2c3d4e5f6g7h8",
  "viewer_timezone": "America/Denver"
}

Returns an error starting Link not found: if the slug is not owned by the caller (and scope wasn’t widened to all). The rest of it names the account that was searched, by its email address, and the slug, so a link created under a different HTMLvault account reads as exactly that. Every tool that takes a slug answers a miss the same way. Timestamps and viewer_timezone work the same as in list_links above.

get_analytics

View metrics for one link: totals, engagement, and breakdowns.

FieldTypeDescription
slugRequiredstringThe link slug.
period"7d" | "30d" | "90d" | "all"Time window for the metrics. Default 30d. (Unrelated to the scope parameter below: this all means all-time.)
scope"me" | "all"Default me (or your stored preference) only finds your own links. all also finds any org member’s link (requires analytics.view_any).

Result

{
  "slug": "a1b2c3d4e5f6g7h8",
  "period": "30d",
  "totals": { "views": 318, "unique_views": 204, "repeat_views": 114, "bot_views": 27 },
  "engagement": { "avg_time_on_page_seconds": 47, "avg_scroll_depth_pct": 68 },
  "breakdowns": {
    "countries": [{ "value": "United States", "views": 190 }],
    "devices":   [{ "value": "desktop", "views": 240 }],
    "channels":  [{ "value": "clay-icp-ceos", "views": 35 }],
    "referrers": [{ "value": "https://mail.google.com/", "views": 60 }]
  }
}

Automated traffic is excluded from views, engagement, and every breakdown; bot hits are counted separately as bot_views so the number is visible without polluting the human totals. Metric definitions match the dashboard exactly. Returns an error starting Link not found:, naming the account searched and the slug, if the slug is not owned by the caller.

Publish HTML as a secure, shareable link. Every external link in it is checked against Google Web Risk, and the content against your account’s PII scan policy, before it goes live. Available on every plan: Free publishes up to 50 links a month with expiry capped at 30 days, paid plans are unlimited with any expiry. The link check only knows sites Google already flags, so it is not a guarantee: some unsafe sites may be missed, and some safe ones flagged in error.

FieldTypeDescription
htmlRequiredstringComplete HTML to publish. 5 MB limit.
titleRequiredstringShort descriptive title shown in your dashboard.
expiresIn"1h" | "24h" | "3d" | "7d" | "30d" | "never"Expiry window. Omit to use your account default.
tracking"none"Set to none to publish with no tracking pixels or default tracking code. Omit to apply your tracking defaults.
domainstringHostname of one of your active custom domains to serve from. Omit to inherit your account default domain.
scan_report"summary" | "none"Response verbosity: see scan_report above. none omits warnings from a successful publish.

Result

{
  "slug": "k9m2p4q7r1s8t3v6",
  "url": "https://htmlvault.io/k9m2p4q7r1s8t3v6",
  "title": "Q3 Proposal"
}

If your account’s PII policy is set to warn (rather than block), the link still publishes and the response includes a warnings array describing the categories found. See Errors for the block cases. A successful MCP create_link fires the link.created webhook.

Start an empty draft and get its slug: step one of append-mode publishing, for a document you intend to build up across several append_html calls instead of sending it in one. Nothing is checked, charged, or served yet: no publish gates run, a Free account’s monthly link allowance is untouched, the slug 404s publicly, and the draft is excluded from list_links. Available on every plan. Use it when a document is big enough that losing one 5 MB create_link call mid-transmit would hurt; for anything that fits comfortably in a single call, plain create_link is one round trip instead of several.

FieldTypeDescription
titleRequiredstringShort descriptive title shown in your dashboard.

Result

{
  "slug": "k9m2p4q7r1s8t3v6",
  "draft_expires_at": "2026-08-18T18:22:04.511Z"
}

A draft lives as long as your account’s data-retention window, with a floor of 24 hours (90 days on the default setting, and the org owner’s window for an Enterprise member). (auto_delete resolves to the 24-hour floor rather than to zero, or a draft would be swept out from under the appends building it.) draft_expires_at is that deadline, not the published link’s expiry. A draft still unfinalized when it passes is deleted outright by the daily retention sweep, content and all. Mid-build you can read it with get_link, which returns the accumulated HTML alongside is_draft: true and the same draft_expires_at. The draft tools take no expiresIn: once finalized, the link takes your account’s default expiry on Pro, Teams and Enterprise (accounts created since October 2026 start with 1 year; change it under Settings, Link defaults), and on Free it expires 30 days after it is published. Either way the clock starts at finalize, not here, so however long you spend assembling the document costs nothing against it.

append_html

Append a chunk to a draft’s stored HTML. The server does the concatenation, so content already sent is never re-transmitted. Call it as many times as the document needs, then once more with finalize: true. That last call is what checks and publishes. Available on every plan.

FieldTypeDescription
slugRequiredstringThe draft slug from create_draft_link.
chunkRequiredstringHTML to append. 200 KB maximum per call, and the draft’s accumulated content may not pass the 5 MB document ceiling create_link enforces.
finalizebooleanDefault false. Set true on the last call to run the publish gates and make the link live.

Result, mid-build (finalize omitted or false)

{
  "slug": "k9m2p4q7r1s8t3v6",
  "content_length": 412800,
  "content_hash": "9f2c1a…",
  "finalized": false
}

Result (finalize: true)

{
  "slug": "k9m2p4q7r1s8t3v6",
  "url": "https://htmlvault.io/k9m2p4q7r1s8t3v6",
  "title": "Q3 Proposal",
  "content_length": 498211,
  "content_hash": "4b7e02…",
  "changed": false,
  "transformations": [],
  "finalized": true
}

Every call reports content_length and content_hash (SHA-256) of the draft’s full accumulated content, not of the chunk, so an agent can confirm each piece landed before sending the next. Between calls the draft is allowed to be incomplete and not well-formed: a half-open <table> at a chunk boundary is normal and is not an error.

Nothing is gated until finalize. The finalize: true call runs the same tail create_link runs, against the whole assembled document: well-formedness, publish-time normalization, your account’s PII policy, and the Google Web Risk link check. Only after those pass does it charge a Free account’s monthly link slot, so a blocked publish never burns one. On success the draft state is cleared, the slug goes live, and the link.created webhook fires (the first and only link.created for that slug).

A failed finalize leaves the draft intact. Malformed HTML, a PII block, unsafe content, or a Free account at its monthly cap all reject before anything is written, so the stored content stays byte-for-byte what it was: every chunk you already sent is still there. Inspect it with get_link, fix the offending part, and call finalize: true again. Because the rejected final chunk was never persisted, retry by resending that chunk.

Append-specific rejections: chunk_too_large (over 200 KB), draft_too_large (this chunk would pass 5 MB), not_a_draft (the slug is already finalized: edit it with update_link or patch_link instead), and draft_expired (past the 24-hour window; start a new draft). None of them append anything. If the PII policy is set to warn rather than block, finalize publishes and adds a warnings array, exactly as create_link does; see Errors for the block cases.

Publish up to 100 links in one call from a single HTML template plus per-row variable substitution: the efficient way to produce many distinct pages (a personalized report per customer, a per-account one-pager) without re-sending the document each time. Paid plans only (Pro, Teams, Enterprise). For many tracked URLs of the same unchanged page, use create_recipient_links instead.

FieldTypeDescription
template_htmlRequiredstringComplete HTML template. {{name}} placeholders are replaced per row. 5 MB limit per substituted document; 20 MB across the whole batch.
linksRequiredarray (1–100)One entry per link, each with a title (required) and an optional vars map of placeholder values, e.g. {"customer": "Acme Corp"}.
expiresIn"1h" | "24h" | "3d" | "7d" | "30d" | "never"Expiry window applied to every link in the batch. Omit to use your account default.
tracking"none"Set to none to attach no pixels or default tracking code to any link in the batch.
domainstringHostname of one of your active custom domains to serve every link in the batch from. Omit to inherit your account default domain.
scan_report"summary" | "none"Response verbosity: see scan_report above. none omits rows_with_warnings entirely.

Request

{
  "template_html": "<!doctype html><h1>{{customer}} — {{quarter}} review</h1>…",
  "links": [
    { "title": "Acme — Q3",   "vars": { "customer": "Acme Corp",  "quarter": "Q3" } },
    { "title": "Globex — Q3", "vars": { "customer": "Globex Inc", "quarter": "Q3" } }
  ]
}

Result

{
  "created": [
    { "slug": "k9m2p4q7r1s8t3v6", "url": "https://htmlvault.io/k9m2p4q7r1s8t3v6", "title": "Acme — Q3" },
    { "slug": "b4n7x2c9d1e5f8g3", "url": "https://htmlvault.io/b4n7x2c9d1e5f8g3", "title": "Globex — Q3" }
  ],
  "count": 2,
  "rows_with_warnings": [1]
}

created is in the same order as the input rows. rows_with_warnings holds the zero-based indices of rows that published with non-blocking PII warnings (omitted under scan_report: "none").

Substitution rules. A {{token}} with no matching var in a row passes through untouched: templates containing Vue or Handlebars syntax are safe. The reverse is an error: a var with no matching token fails its row with unknown_var, so a typo like {{first_name}} vs firstName is caught instead of quietly publishing a page missing its personalization. Substituted values are never re-scanned for placeholders, so one row’s value cannot expand into another row’s token.

All-or-nothing. Any invalid row, any row blocked by your PII policy, and any flagged URL rejects the entire batch. Nothing is created. Failures come back in a rows array of { index, error, message?, detail? } so you can fix the offending rows and retry the whole call.

The gates run batched, not weakened: one Google Web Risk check covers the union of every row’s external URLs, each URL checked once however many rows link it, while the PII policy is evaluated per substituted document at full fidelity. Each created link fires its own link.created webhook. Batch creation draws on a per-account budget of 1,000 links per minute, counted in links rather than calls (see Limits & errors).

Replace the HTML of an existing link in place: the slug and public URL are unchanged, so a link you have already shared updates live. Re-runs the same Google Web Risk and PII policy gates as publishing, on every plan. For anything short of a full rewrite, prefer patch_link. It sends only the changed parts.

FieldTypeDescription
slugRequiredstringThe link slug to update.
htmlRequiredstringNew HTML content. 5 MB limit.
scope"me" | "all"Default me (or your stored preference) only edits your own links. all also allows editing any org member’s link (requires links.edit_any).
scan_report"summary" | "none"Response verbosity: see scan_report above. none omits warnings from a successful update.

Result

{
  "slug": "k9m2p4q7r1s8t3v6",
  "url": "https://htmlvault.io/k9m2p4q7r1s8t3v6",
  "updated_at": "2026-06-13T15:04:11.882Z"
}

Returns an error starting Link not found or not owned:, naming the account searched and the slug, if the slug isn’t yours (and scope wasn’t widened to all). Fires the link.updated webhook on success, against the link’s actual owner.

Edit a published link by sending only the changed parts. Each patch replaces an exact substring of the stored HTML (the same find/replace semantics as an editor, or Claude Code’s Edit tool). Deliberately no regex: exact matching is the only contract an agent can reason about reliably. Same publish gates as update_link.

FieldTypeDescription
slugRequiredstringThe link slug to patch.
patchesRequiredarray (1–200)Each entry takes find (exact substring to replace, required, non-empty), replace (replacement text, required. An empty string deletes the match), and optional replace_all. Applied in order, each against the result of the previous.
mode"unique" | "positional"Default unique: without replace_all, a find string that occurs more than once fails as ambiguous. positional skips that check and applies each patch to the first occurrence of its find string in the running document (see below).
scope"me" | "all"Default me (or your stored preference) only edits your own links. all also allows editing any org member’s link (requires links.edit_any).
scan_report"summary" | "none"Response verbosity: see scan_report above. none omits warnings from a successful patch.

Patches match the stored document, not the HTML you sent. HTMLvault sanitizes content at publish time and serves the sanitized version, so that’s what a find string is compared against: exactly the html_content that get_link returns. If a patch surprises you with not_found, re-fetch with get_link and copy the find string from there.

In the default unique mode, without replace_all, the find string must occur exactly once. Include enough surrounding context to make it unique. The whole batch is all-or-nothing: the first failing patch aborts the call and nothing is persisted.

Request

{
  "slug": "k9m2p4q7r1s8t3v6",
  "patches": [
    { "find": "<h1>Q3 Proposal</h1>", "replace": "<h1>Q4 Proposal</h1>" },
    { "find": "Draft — do not circulate", "replace": "", "replace_all": true }
  ]
}

Result

{
  "slug": "k9m2p4q7r1s8t3v6",
  "url": "https://htmlvault.io/k9m2p4q7r1s8t3v6",
  "updated_at": "2026-07-24T15:04:11.882Z",
  "patches_applied": 2,
  "replacements": 4
}

patches_applied counts the patches; replacements counts the substrings actually replaced across all of them (higher when replace_all matched more than once).

Two situations defeat uniqueness however much context you include, and both call for mode: "positional". Rebuilt, minified output, where identifier mangling makes short strings recur by construction. And several structurally identical blocks (repeated cards or rows), where a find string that was unique against the original document turns ambiguous mid-call, once an earlier patch has changed one of the copies. In positional mode each patch applies to the first occurrence of its find string in the running document, so order your patches to match the order the target text appears. not_found still fails the call, and replace_all still replaces every occurrence.

Positional request

{
  "slug": "k9m2p4q7r1s8t3v6",
  "mode": "positional",
  "patches": [
    { "find": "<span class=\"price\">$10</span>", "replace": "<span class=\"price\">$12</span>" },
    { "find": "<span class=\"price\">$10</span>", "replace": "<span class=\"price\">$15</span>" }
  ]
}

A patch that can’t apply returns enough detail to repair the request without guessing:

Patch failure

{
  "error": "patch_failed",
  "failed_patch": 1,
  "reason": "ambiguous",
  "occurrences": 3,
  "message": "Patch at index 1: the find string occurs 3 times — include more surrounding context to make it unique, or set replace_all: true to replace every occurrence."
}

reason is not_found (occurrences: 0) or ambiguous (more than one occurrence without replace_all, unique mode only), and failed_patch is the zero-based index into your patches array. A link whose content has been purged by your retention policy returns content_unavailable: there is nothing stored to patch against, so use update_link with the full document instead. On success, fires the link.updated webhook, same as update_link.

Delete one of your links.

FieldTypeDescription
slugRequiredstringThe link slug to delete.
scope"me" | "all"Default me (or your stored preference) only deletes your own links. all also allows deleting any org member’s link (requires links.delete_any).

Deletion is immediate and irreversible. The link row is hard-deleted and all of its view records are removed with it (cascade). It does not pass through the retention window. There is no recovery period. A link under a legal hold cannot be deleted.

Result

{ "deleted": "k9m2p4q7r1s8t3v6" }

A link under legal hold returns { "error": "legal_hold", "message": "This link is under a legal hold and cannot be deleted." }. A slug you don’t own (or already deleted) returns an error starting Link not found or already deleted:, naming the account searched and the slug.

scan_html

Run the regex PII & secret scanner against HTML without publishing, for content you are not (yet) publishing. You do not need to call this before a publish: the four publishing tools run this same scan plus the Google Web Risk link check server-side and reject blocked content with the findings in the error, so pre-scanning doubles the work for no added safety.

FieldTypeDescription
htmlRequiredstringHTML content to scan.

Result

{
  "hasSensitiveData": true,
  "findings": [
    { "type": "api_key", "label": "API Key", "count": 1 },
    { "type": "email",   "label": "Email Address", "count": 3 }
  ]
}

This is the zero-token regex scanner only: it reports findings but never blocks. The publish gates on create_link, create_links, update_link, and patch_link are what enforce your policy.

Mint per-recipient tracked URLs for a link you own. Batch up to 500 in one call. Each recipient gets a unique slug that resolves to the parent link’s HTML and stamps individual attribution into view records. A supplied email is never stored: it is kept only as a keyed HMAC-SHA256 hash. Paid plans only (Pro, Teams, Enterprise); Free accounts are refused.

FieldTypeDescription
slugRequiredstringThe parent link slug to mint recipients for.
recipientsRequiredarray (1–500)Each entry accepts ref (opaque external ID, not an email), label (display name), email (hashed before storage), and per-recipient utm_source / utm_medium / utm_campaign / utm_term / utm_content overrides, all optional.

Result

{
  "recipients": [
    { "recipient_ref": "crm-4471", "url": "https://htmlvault.io/k9m2p4q7-a1b2" }
  ]
}

Returns { "error": "..." } on a Free-plan account, on an org role lacking recipient permissions, or when the parent slug isn’t owned by the caller. A successful call fires the recipient.created webhook once per call (not once per recipient).

Workflow recipes

Publish straight from a chat

Ask the agent to generate an HTML artifact (a proposal, dashboard, or one-pager), then “vault this.” The agent calls create_link with the HTML and a short title and returns the URL. Because the publish gates run server-side, anything that trips your PII policy or links to a site Google Web Risk flags is caught before the link exists.

Living dashboards

Publish a status page once with create_link, share the URL, then have the agent refresh it on a schedule with patch_link, swapping just the numbers rather than re-uploading the page every time (update_link is there for the occasional full rewrite). The slug never changes, so recipients always see current data at the same link, and get_analytics tells you who’s actually opening it.

Personalized runs

Building the same report for 80 accounts? Write the page once with {{account}}-style placeholders and hand it to create_links with one row per account: the template crosses the wire once instead of 80 times, and you get 80 slugs back in a single response. If the page content is identical for everyone and you only need to know who opened it, publish once and mint tracked URLs with create_recipient_links instead.

Documents too big for one call

A 4 MB report is one create_link call that either lands or is lost. Instead, open a draft with create_draft_link, stream it in with append_html a section at a time (checking content_length as you go), and publish with finalize: true on the last chunk. A dropped connection costs one chunk, not the document, and the gates still see the whole page at the end. The draft is not reachable by anyone until they pass.

Agent pipelines with notifications

When an automated pipeline creates links via MCP, subscribe your stack to the matching webhook events: link.created to log the new URL, link.viewed to react to engagement. The agent publishes; your systems are notified out-of-band without polling.

Limits & errors

Rate limit

Requests are rate-limited per credential to 100 requests per minute (fixed one-minute window): per API key, or per signed-in user across all their OAuth clients. Over the limit returns 429 with a Retry-After header:

429 Too Many Requests

{ "error": "rate_limited", "message": "Too many requests. Please slow down and retry after the window resets." }

The exact ceiling may be tuned over time; clients should honor Retry-After rather than hard-code the value.

Batch budget

create_links carries a second, row-weighted limit on top of the per-key request limit: 1,000 links per minute per account. It counts links, not calls (ten calls of 100 rows exhaust it just as one call of 1,000 would), and it is keyed per account, so issuing extra API keys does not widen it. Over budget returns a rate_limited tool result carrying retryAfterSeconds, and nothing is created.

Error catalog

Tool-level errors are returned as a JSON object in the content envelope (transport status 200); auth and rate-limit errors are returned at the HTTP layer with the status shown. Publish-gate blocks (pii_detected, pii_blocked, unsafe_content, too_many_urls, unverifiable_content, safety_check_unavailable, policy_unavailable, document_too_large, and the Free plan’s link-check rate_limited) additionally set the MCP isError flag on the result.

FieldTypeDescription
pii_detectedtool resultPII policy is set to block and the content matched. Shape: { "error": "pii_detected", "message": "...", "findings": [...], "rule": "...", "riskScore": 26 }. message names the matched categories and the blocking rule. From create_link / update_link / patch_link, and from append_html with finalize: true.
pii_blockedtool resultThe create_links equivalent: one or more rows tripped a block rule, so the whole batch was rejected. Per-row detail (findings, rule, risk score) rides the rows array; nothing was created.
unsafe_contenttool resultGoogle Web Risk flags a link in the content as suspected phishing, malware, or unwanted software. Shape: { "error": "unsafe_content", "message": "...", "threatTypes": [...], "advisory": { "text": "Advisory provided by Google", "url": "..." }, "learnMore": [{ "label": "...", "url": "..." }] }. The message already carries Google’s attribution and learn-more links. If you show the warning to a person, keep both: Google’s Web Risk terms require them, and a flag is a suspicion, not a certainty. From create_links it also carries matched_urls and the rows that referenced them.
too_many_urlstool resultThe content links to more distinct external URLs than one publish can check against Google Web Risk: 100 on the Free plan, 500 on paid plans, 2,000 for a whole create_links batch (1,000 on Pro, its day of link checks). For an edit, only the URLs it adds count. Shape: { "error": "too_many_urls", "message": "...", "urlCount": 612, "limit": 500 }. Nothing was published; remove links or split the content.
unverifiable_contenttool resultThe content nests documents (an iframe srcdoc, or a data: HTML or SVG document) more than 3 levels deep, or puts more markup inside such documents than can be read, so the links inside could not all be read and checked against Google Web Risk. Shape: { "error": "unverifiable_content", "message": "..." }. From create_links it also carries the rows that nest too deep. Nothing was published; reduce the nesting.
safety_check_unavailabletool resultFree plan only. Google Web Risk could not check every link just now (an outage, a timeout, or the checks HTMLvault sets aside for Free publishing are used up for now), and a Free publish only goes live once every link has been checked. Shape: { "error": "safety_check_unavailable", "message": "..." }. Nothing was published and a draft is left intact; retry after the wait its message gives (usually a minute). On a paid plan the publish goes through instead. Over REST this is HTTP 503 with Retry-After.
policy_unavailabletool resultYour account’s PII publishing rules could not be checked just now (a temporary error on our side), and content is only published once they have been. Shape: { "error": "policy_unavailable", "message": "..." }. Nothing was published and a draft is left intact; retry in about 30 seconds. From every publishing tool, create_links included. Over REST this is HTTP 503 with Retry-After.
document_too_largetool resultThe document as it would be stored is over 5 MB. The limit counts bytes after cleanup, so content under 5 MB as sent can still go over: each bare & is stored as &amp;, and a non-ASCII character takes 2 to 4 bytes. Shape: { "error": "document_too_large", "message": "...", "bytes": 5400000, "limit": 5242880 }. From every publishing tool; create_links names the failing rows inside invalid_rows. Nothing was published and a draft is left intact. Over REST this is HTTP 413.
unknown_domaintool resultThe domain passed to create_link or create_links isn’t an active domain on your account.
patch_failedtool resultA patch_link patch could not apply. Shape: { "error": "patch_failed", "failed_patch": 1, "reason": "ambiguous", "occurrences": 3, "message": "..." }. reason is not_found or ambiguous. Nothing was persisted.
content_unavailabletool resultFrom patch_link on a link whose stored HTML was purged by your retention policy: there is no document to patch against. Use update_link with the full HTML instead.
invalid_rowstool resultFrom create_links: one or more rows failed validation before any gate ran, most often unknown_var (a var with no matching {{token}}) or row_too_large. The rows array names each failing index; nothing was created.
too_many_rows / batch_too_large / template_too_largetool resultFrom create_links: more than 100 rows, more than 20 MB of content across the batch (as substituted, or as it would be stored), or a template over the 5 MB document limit.
pro_requiredtool resultFree plan calling a paid-only tool: create_links. create_recipient_links rejects the same case as a plain error message rather than this code. (Distinct from the HTTP 403 plan_required, which stops a key-authenticated Free caller before any tool runs; an OAuth-authenticated Free caller connects and gets these instead.)
link_limit_reachedtool resultFree plan at its 50-links-per-calendar-month ceiling on create_link, or on the append_html call that finalizes a draft. Shape: { "error": "link_limit_reached", "message": "..." }. Nothing was created, and a draft is left intact.
chunk_too_large / draft_too_largetool resultFrom append_html: the chunk is over the 200 KB per-call limit, or appending it would push the draft past the 5 MB document ceiling. Nothing was appended.
not_a_draft / draft_expiredtool resultFrom append_html: the slug is a finalized link (use update_link or patch_link), or the draft passed its 24-hour window and was removed (start a new one with create_draft_link).
malformed_htmltool resultPublishing requires balanced tags. Carries reason, tag, position, and a context slice of the document around the problem. From create_link / update_link / patch_link, and from a finalizing append_html, where the assembled draft, not the chunk, is what is checked.
invalid_cursor / invalid_created_after / invalid_created_beforetool resultFrom list_links: the cursor is not a next_cursor this server returned, or a date filter is not an ISO 8601 timestamp (a date alone, such as 2026-07-22, reads as midnight UTC). Shape: { "error": "invalid_cursor", "message": "..." }. Pass next_cursor back unchanged; nothing was listed.
legal_holdtool resultFrom delete_link on a held link. Shape: { "error": "legal_hold", "message": "..." }.
scope: "all" deniedtool resultPassed scope: "all" (or it’s your stored default) on list_links, get_link, get_analytics, update_link, patch_link, or delete_link without the matching *_any permission on your role. Never a silent fallback to your own links. Returns { "error": "scope: 'all' requires the '...' permission, which your role does not hold." }.
rate_limited429 / tool resultPer-key request limit exceeded (429), or the per-account create_links row budget exhausted (tool result with retryAfterSeconds); see above. Also a publish or edit that would check new links against Google Web Risk after the account has used its link checks: on the Free plan 100 a minute and 200 a day, on Pro 1,000 a day, on Teams 5,000 and on Enterprise 10,000 (days counted from midnight Pacific time); its message says how long to wait, and nothing was published.
missing_key / invalid_key401No bearer header / unrecognized or revoked key.
plan_required403Valid API key on a Free account; keys need Pro, Teams or Enterprise. OAuth callers never see this.
email_unverified403OAuth caller whose email address is not verified. Sign in at https://htmlvault.com with the code we email you, then reconnect.

Subscribing to events instead of polling? See the Webhooks reference.