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, read, analytics, delete and scan tools within the same limits as the web upload form. Static API keys, editing after publish, batch creation and recipient links are Pro and 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 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: eight of the twelve tools, with publishing capped at 50 links a month and 30-day expiry. Pro 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 Pro or Enterprise. For everything else, generate an API key (Pro 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 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_linkto 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 are Pro and Enterprise only; 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 requires a Pro or Enterprise plan. Upgrade at https://htmlvault.com/pricing”
upstream_error503OAuth token verified but identity resolution hit a temporary upstream failure. Retry after the Retry-After interval.

The 401 cases share a shape ({ "error": "...", "message": "..." }); the key-on-Free case is a distinct 403so 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 are Pro and Enterprise only, 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 & 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 never expires
list_linksYesYes
get_linkYesYes
get_analyticsYes, the same views, engagement and breakdownsYes
delete_linkYesYes
scan_htmlYesYes
update_linkNo, returns pro_requiredYes
patch_linkNo, returns pro_requiredYes
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 with an upgradeUrl, 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 editing after publish. update_link and patch_link both return pro_required. Get the document right before calling create_link, or delete and republish, which spends another link from the allowance.
  • 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. Because Free cannot edit after publish, put the tag in the HTML up front: it starts displaying on upgrade with no republish.

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, eight 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 structuredContentvalidated 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_timezoneis 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 { "error": "Link not found" } if the slug is not owned by the caller (and scope wasn’t widened to all). 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 { "error": "Link not found" } if the slug is not owned by the caller.

Publish HTML as a secure, shareable link. Content is checked against Google Safe Browsing and 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, Pro and Enterprise are unlimited with any expiry.

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 and do not inherit your account default expiry: once finalized, the link never expires on Pro and Enterprise, and on Free it expires 30 days after it is published — the ceiling is stamped 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 Google Safe Browsing — and 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. Pro/Enterprise feature. 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 firstNameis 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: oneGoogle Safe Browsing call covers the union of every row’s external URLs, 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 Safe Browsing and PII policy gates as publishing. Editing after publish is a Pro/Enterprise feature. 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 { "error": "Link not found or not owned" } if the slug isn’t yours (and scope wasn’t widened to all). Fires the link.updatedwebhook 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 Pro/Enterprise requirement and same publish gates as update_link.

FieldTypeDescription
slugrequiredstringThe link slug to patch.
patchesrequiredarray (1–20)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.
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.

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).

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), 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 { "error": "Link not found or already deleted" }.

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 Safe Browsing 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 hashed with SHA-256 before storage — never kept in plain text. Pro/Enterprise feature; Free accounts are blocked.

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 Safe Browsing 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_analyticstells 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) 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 resultContent links to a known-malicious site (Safe Browsing). Shape: { "error": "unsafe_content", "message": "...", "threatTypes": [...] }. From create_links it also carries matched_urls and the rows that referenced them.
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 substituted content across the batch, or a template over the 5 MB document limit.
pro_requiredtool resultFree plan calling a paid-only tool: update_link, patch_link, or 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": "...", "upgradeUrl": "https://htmlvault.com/upgrade" }. 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.
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.
missing_key / invalid_key401No bearer header / unrecognized or revoked key.
plan_required403Valid API key on a Free account; keys are Pro and Enterprise only. OAuth callers never see this.

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