DocsUpdated 2026-10-27View as Markdown

Ollav MCP server

Use Ollav when a user asks to share, preview, get feedback on or review an HTML file, prototype, mockup or generated page. Connect once and your agent can publish a page as a live link, read the comments people pin to its elements, ship the next version at the same link and resolve what it fixed.

  • Endpoint: https://app.ollav.app/mcp, Streamable HTTP, stateless (no sessions, no SSE stream). Protocol versions 2024-11-05 to 2025-11-25.
  • Auth: an API key as Authorization: Bearer ol_test_..., the key in the URL for clients that can't send headers, or OAuth. See Auth options.
  • Start with a test key. Create one at https://app.ollav.app/keys and choose Test. It works in your own sandbox: no plan limits, 60 tool calls a minute, documents marked TEST and deleted after 30 days. Switch to a live key (ol_live_...) to share with your team.
  • New to it? The MCP quickstart gets you from nothing to a shared, reviewed page in five minutes.

Every example on this page uses $OLLAV_API_KEY:

export OLLAV_API_KEY=ol_test_...

Connect your client

Claude Code

Install the plugin (the MCP server plus a skill that tells Claude when to use Ollav):

/plugin marketplace add ollav-app/ollav
/plugin install ollav@ollav

Or add just the MCP server:

claude mcp add --transport http ollav https://app.ollav.app/mcp --header "Authorization: Bearer $OLLAV_API_KEY"

Check it: claude mcp list shows ollav as connected, and /mcp inside Claude Code lists its 11 tools.

Claude.ai and Claude Desktop

Add a custom connector. Claude signs you in with OAuth, and you pick the workspace it may use:

Settings → Connectors → Add custom connector
Name: Ollav
URL:  https://app.ollav.app/mcp

OAuth always gives Claude a live key. To try Ollav in test mode first, use the key-in-path URL instead: https://app.ollav.app/mcp/ol_test_YOUR_KEY. That URL contains the key, so treat it like a password.

Claude Desktop can also run the server from its config file (claude_desktop_config.json) through the mcp-remote bridge:

{
  "mcpServers": {
    "ollav": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://app.ollav.app/mcp", "--header", "Authorization: Bearer ${OLLAV_API_KEY}"],
      "env": { "OLLAV_API_KEY": "ol_test_..." }
    }
  }
}

Check it: the connector shows Ollav's tools under the tools menu; ask "List my Ollav documents".

Cursor

~/.cursor/mcp.json (or .cursor/mcp.json in a project):

{
  "mcpServers": {
    "ollav": {
      "url": "https://app.ollav.app/mcp",
      "headers": { "Authorization": "Bearer ${env:OLLAV_API_KEY}" }
    }
  }
}

Cursor reads OLLAV_API_KEY from the environment it was started in. Check it: Settings → MCP shows ollav with a green dot and 11 tools.

ChatGPT

Add Ollav as an app. You sign in to Ollav once and pick the workspace ChatGPT may use; there is no key to copy:

Settings → Apps & Connectors → Create (developer mode; menu names change over time)
Name:            Ollav
MCP server URL:  https://app.ollav.app/mcp
Authentication:  OAuth

To try it in test mode, set MCP server URL to https://app.ollav.app/mcp/ol_test_YOUR_KEY and Authentication to none. ChatGPT stores that URL, and the key in it, like a password.

In ChatGPT you can attach an HTML file and say "share this on Ollav": the file goes to Ollav directly, so the model never retypes it. Results show in a card with the share link and the pinned comments.

Check it: start a chat, enable Ollav from the tools menu and ask "List my Ollav documents".

Any other MCP client

Point it at the Streamable HTTP endpoint and send the key as a header:

{
  "mcpServers": {
    "ollav": {
      "type": "http",
      "url": "https://app.ollav.app/mcp",
      "headers": { "Authorization": "Bearer ol_test_..." }
    }
  }
}

The field names vary by client (url, serverUrl, httpUrl); the agent quickstart has ready-made entries for Codex, VS Code, Windsurf and Gemini CLI. A client that only launches local stdio servers can bridge with npx -y mcp-remote https://app.ollav.app/mcp --header "Authorization: Bearer $OLLAV_API_KEY".

What the server does, for client authors: every message is one JSON-RPC 2.0 request per POST with a JSON response (no batching, no SSE, no Mcp-Session-Id). Notifications get 202. GET and DELETE /mcp answer 405. The server card is at /.well-known/mcp.json.

Auth options

OptionUse it forHow
API key headerCoding agents and any client that can send headersAuthorization: Bearer ol_test_... or ol_live_..., from https://app.ollav.app/keys
Key in the URLClients that can't send headershttps://app.ollav.app/mcp/ol_test_.... The URL is then a secret: don't paste it in chats or commit it
OAuthClaude.ai, Claude Desktop, ChatGPT and other OAuth clientsAdd https://app.ollav.app/mcp with no key. The client finds the OAuth server through WWW-Authenticate on the 401

Test or live. A key's prefix is its mode. ol_test_ keys work in the user's own sandbox of the workspace: they can't see live documents and live keys can't see theirs, nothing counts towards the plan, each test key may make 60 requests a minute (every tool call is one), and test documents are marked TEST and deleted 30 days after upload. ol_live_ keys (and older sk_live_ keys) work on the team's real data.

Scopes. Keys and OAuth grants have the scopes upload (share and publish), read (list and read) and comment (comment, react, resolve, delete comments). Each tool below names the scope it needs.

OAuth details. Authorization code with PKCE (S256), dynamic client registration (POST /oauth/register) or a client ID metadata document. Resource metadata: /.well-known/oauth-protected-resource/mcp. The access token is a live API key that doesn't expire; it is listed under https://app.ollav.app/keys as "<app> (OAuth)", where the user can revoke it. Clients revoke with POST /oauth/revoke.

Verify the connection

Ask the server for its tools with the same key the client uses:

curl -s https://app.ollav.app/mcp \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

A working key returns {"jsonrpc":"2.0","id":1,"result":{"tools":[...]}} with 11 tools. A bad one returns HTTP 401 and a JSON-RPC error whose data.code is AUTH_REQUIRED (see Troubleshooting).

Then make one real call:

curl -s https://app.ollav.app/mcp \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_documents","arguments":{}}}'

In the client itself, ask: "List my Ollav documents." The agent should call list_documents. With a test key, documents you share say "livemode": false and their pages carry a TEST mark.

Tools

Every tool declares readOnlyHint, destructiveHint, idempotentHint and openWorldHint, an outputSchema (successful results carry structuredContent), and the OAuth scope it needs. Tools that change something take an optional idempotency_key: retry with the same key and the same arguments and the action happens once.

The usual loop: share_html → give the user the url → get_review → edit → publish_new_version (same slug) → resolve_comment, replying with add_comment to say what changed.

share_html

Share HTML as a live, commentable link. Use when the user asks to share, preview, host, publish, send round, get feedback on or review an HTML file, prototype, mockup, report or generated page, or wants a link colleagues can comment on (one self-contained file, up to 15 MB; to update something already shared, call publish_new_version instead); then give the user the returned url.

When not to use it. Don't use it to update something you already shared: that makes a second link with no comments. Call publish_new_version with the slug instead. Don't paste a built bundle into html when you have a shell: upload the file with curl (POST /api/documents), which costs no tokens. Not for multi-file sites, server-side apps or non-HTML files.

PropertyValue
AnnotationsreadOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true
OAuth scope / key scopeupload
Aliasesupload_document
REST equivalentPOST /api/documents

Inputs

ArgumentTypeRequiredDescription
htmlstringThe full HTML content to host. Provide this or file. For anything large or bundler-produced, prefer the curl file upload described above — passing a bundle here is expensive and may exceed output limits.
titlestringOptional document title. Defaults to the page <title> tag or filename.
filenamestringOptional filename. Re-uploading the same filename creates a new version on the same slug.
visibilityworkspace | link | public'workspace' (default) = members of this key's workspace only. 'link' = anyone signed in to Ollav with the URL. 'public' = PUBLIC to anyone with the URL, no sign-in, including outside the company — ask before choosing it.
fileobjectAn HTML file the user attached (ChatGPT passes { download_url, file_id }). Ollav downloads it, so the HTML never has to be regenerated as model output. Provide this or html.
idempotency_keystringOptional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours.

Output (structuredContent; the same JSON is the last text block)

FieldTypeAlways presentDescription
urlstringyesThe share link. Give it to the user.
slugstringyesKeep it for publish_new_version and get_review.
documentIdstring
versionIdstringyes
hashstring
dedupedboolean
versionedboolean
visibilitystring
warningsarray of string

Example call:

{
  "name": "share_html",
  "arguments": {
    "html": "<!DOCTYPE html><html><head><title>Pricing page v2</title></head><body><h1>Pricing</h1></body></html>",
    "filename": "pricing.html",
    "idempotency_key": "5f0c3b1e-8a2d-4c6f-9e7a-0b1c2d3e4f50"
  }
}

Result:

{
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "slug": "qkjgjQCQGz",
  "documentId": "9908f969-9774-4374-a5bf-d66abcfbe70b",
  "versionId": "b8cfa354-0c7c-4102-bf14-69b49de24ca3",
  "deduped": false,
  "versioned": false,
  "visibility": "workspace",
  "warnings": [],
  "livemode": false
}

The full description, as tools/list sends it to the model:

Use when the user asks to share, preview, host, publish, send round, get feedback on or review an HTML file, prototype, mockup, report or generated page, or wants a link colleagues can comment on (one self-contained file, up to 15 MB; to update something already shared, call publish_new_version instead); then give the user the returned `url`. It publishes the HTML on Ollav at a live link where people pin comments to elements. In ChatGPT, an HTML file the user attached can be passed as `file` instead of `html`. Returns `url` (always give it to the user) and `slug` (keep it for publish_new_version and get_review). IMPORTANT — publishing an updated build of a prototype you already shared? Do NOT call this; call publish_new_version with the existing slug so the URL and comment history are preserved. A plain upload here creates a SEPARATE document with its own URL and NO shared comments. The document lands in the workspace this API key belongs to. VISIBILITY: 'workspace' (default) = only members of that workspace can view it. 'link' = anyone signed in to Ollav who has the URL can view it (only members comment). 'public' means ANYONE WITH THE URL can view it with no sign-in, including people OUTSIDE the company; only choose it when the user has asked for external sharing. Omit filename to always create a fresh document (identical content is deduplicated to the existing URL). Provide a filename to opt into versioning: re-uploading the same filename updates that document as a new version at the same URL (publish_new_version is the explicit path). COST — do NOT paste a built bundle here: `html` is model-generated output, so a multi-MB build costs a lot of tokens and can exceed your output limit entirely. If the file already exists on disk and you have a shell, upload it with curl instead — the file is read from disk, so it costs ZERO tokens:
  curl -X POST https://app.ollav.app/api/documents \
    -H "Authorization: Bearer $OLLAV_API_KEY" -H "X-Ollav-Client: agent" \
    -F "file=@dist/index.html"
Building a React/Vue app? Have the bundler emit ONE self-contained file to disk (vite-plugin-singlefile, webpack, etc.) and curl that — never regenerate a bundle as model output. Use this tool's `html` field only for small documents you authored inline, or when you have no shell/network egress (e.g. claude.ai or Cowork sandboxes), where MCP is the only channel.
Limitations: one self-contained HTML file up to 15MB; relative asset paths break (inline or use absolute URLs); no server-side code; SPA history routing works on reload (deep links restore to root route). The response may include advisory warnings[].

publish_new_version

Publish a new version at the same link. Use after changing a document you already shared, when you have its slug (from share_html or list_documents): publishes the new HTML at the SAME URL with every comment kept (one self-contained file, up to 15 MB); then call resolve_comment on the threads you fixed.

When not to use it. Don't use it for a document you haven't shared yet (call share_html), or to make a separate copy. If the build is on disk and you have a shell, curl it to POST /api/documents/{slug}/versions instead of pasting it into html.

PropertyValue
AnnotationsreadOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true
OAuth scope / key scopeupload
Aliasesnew_version
REST equivalentPOST /api/documents/{slug}/versions

Inputs

ArgumentTypeRequiredDescription
slugstringyesThe document slug (from share_html or list_documents).
htmlstringThe new HTML content. Provide this or file.
titlestringOptional updated title.
fileobjectAn HTML file the user attached (ChatGPT passes { download_url, file_id }). Ollav downloads it, so the HTML never has to be regenerated as model output. Provide this or html.
idempotency_keystringOptional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours.

Output (structuredContent; the same JSON is the last text block)

FieldTypeAlways presentDescription
urlstringyesThe share link. Give it to the user.
slugstringyesKeep it for publish_new_version and get_review.
documentIdstring
versionIdstringyes
hashstring
dedupedboolean
versionedboolean
visibilitystring
warningsarray of string

Example call:

{
  "name": "publish_new_version",
  "arguments": {
    "slug": "qkjgjQCQGz",
    "html": "<!DOCTYPE html><html><body><h1>Pricing</h1><p>Annual plans now 20% off.</p></body></html>"
  }
}

Result:

{
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "slug": "qkjgjQCQGz",
  "versionId": "0d6c1f4e-2b3a-4c5d-8e9f-a0b1c2d3e4f5",
  "deduped": false,
  "versioned": true,
  "warnings": [],
  "livemode": false
}

The full description, as tools/list sends it to the model:

Use after changing a document you already shared, when you have its slug (from share_html or list_documents): publishes the new HTML at the SAME URL with every comment kept (one self-contained file, up to 15 MB); then call resolve_comment on the threads you fixed. Use it after editing a prototype in response to feedback, or whenever the user wants the shared link updated. Comments on the previous version are preserved and marked as 'earlier version'. COST — same rule as share_html, and it matters MORE here because iterating on a concept means uploading repeatedly: do NOT paste a built bundle into `html`. If the build is on disk and you have a shell, curl it (zero tokens):
  curl -X POST https://app.ollav.app/api/documents/{slug}/versions \
    -H "Authorization: Bearer $OLLAV_API_KEY" -H "X-Ollav-Client: agent" \
    -F "file=@dist/index.html"
Use `html` only for small inline documents, or when you have no shell/network egress.
Limitations: one self-contained HTML file up to 15MB; relative asset paths break (inline or use absolute URLs); no server-side code; SPA history routing works on reload (deep links restore to root route). The response may include advisory warnings[].

list_documents

List shared documents. Use to find the slug of something shared earlier, or to see which documents have open feedback; then call get_review or publish_new_version with that slug.

When not to use it. Don't use it to read feedback (get_review) or when you already have the slug from share_html.

PropertyValue
AnnotationsreadOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false
OAuth scope / key scoperead
Aliasesnone
REST equivalentGET /api/dashboard

Inputs

None.

Output (structuredContent; the same JSON is the last text block)

FieldTypeAlways presentDescription
workspaceobject or null
documentsarray of objectyes
sharedWithMearray of object

Example call:

{
  "name": "list_documents",
  "arguments": {}
}

Result:

{
  "workspace": {
    "id": "w_8c1e",
    "name": "Acme"
  },
  "documents": [
    {
      "slug": "qkjgjQCQGz",
      "title": "Pricing page v2",
      "url": "https://content.ollav.app/p/qkjgjQCQGz",
      "visibility": "workspace",
      "versionCount": 2,
      "commentCount": 3,
      "unresolvedCount": 1
    }
  ],
  "sharedWithMe": []
}

The full description, as tools/list sends it to the model:

Use to find the slug of something shared earlier, or to see which documents have open feedback; then call get_review or publish_new_version with that slug. Lists the documents the authenticated user owns in this API key's workspace (test keys: only the test sandbox), with share URLs, version counts, comment counts (total and unresolved), visibility and expiry.

get_document

Get a document and its versions. Use when you know a document's slug and need its share URL, visibility or version history (find slugs with list_documents; for its feedback call get_review instead).

When not to use it. Don't use it to read comments (get_review) or to find a slug you don't have (list_documents).

PropertyValue
AnnotationsreadOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false
OAuth scope / key scoperead
Aliasesnone
REST equivalentGET /api/documents/{slug}

Inputs

ArgumentTypeRequiredDescription
slugstringyesThe document slug.

Output (structuredContent; the same JSON is the last text block)

FieldTypeAlways presentDescription
slugstringyes
titlestring or null
urlstringyes
visibilitystring
currentVersionIdstring or null
versionsarray of objectyes

Example call:

{
  "name": "get_document",
  "arguments": {
    "slug": "qkjgjQCQGz"
  }
}

Result:

{
  "slug": "qkjgjQCQGz",
  "title": "Pricing page v2",
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "visibility": "workspace",
  "currentVersionId": "0d6c1f4e-2b3a-4c5d-8e9f-a0b1c2d3e4f5",
  "versions": [
    {
      "id": "0d6c1f4e-2b3a-4c5d-8e9f-a0b1c2d3e4f5",
      "createdAt": 1793145600000
    },
    {
      "id": "b8cfa354-0c7c-4102-bf14-69b49de24ca3",
      "createdAt": 1793059200000
    }
  ]
}

The full description, as tools/list sends it to the model:

Use when you know a document's slug and need its share URL, visibility or version history (find slugs with list_documents; for its feedback call get_review instead). Returns metadata, the share URL and every version.

get_comments

Get every comment (full thread data). Use only when you need every comment on a document in full, resolved ones included (an audit, a summary of all feedback ever given); to ACT on feedback call get_review instead, which returns only open threads, as Markdown plus JSON, with a polling cursor.

When not to use it. Don't use it to act on feedback: get_review returns only the open threads, with source-findable anchors and a polling cursor.

PropertyValue
AnnotationsreadOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false
OAuth scope / key scoperead
Aliasesnone
REST equivalentGET /api/comments

Inputs

ArgumentTypeRequiredDescription
slugstringyesThe document slug.

Output (structuredContent; the same JSON is the last text block)

FieldTypeAlways presentDescription
slugstringyes
currentVersionIdstring or null
countinteger
unresolvedCountinteger
commentsarray of objectyes

Example call:

{
  "name": "get_comments",
  "arguments": {
    "slug": "qkjgjQCQGz"
  }
}

Result:

{
  "slug": "qkjgjQCQGz",
  "currentVersionId": "0d6c1f4e-2b3a-4c5d-8e9f-a0b1c2d3e4f5",
  "count": 1,
  "unresolvedCount": 1,
  "comments": [
    {
      "id": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
      "parentId": null,
      "body": "Can the annual toggle default to on?",
      "markEmoji": null,
      "selector": "#billing-toggle",
      "elementText": "Monthly / Annual",
      "createdAt": "2026-10-27T09:12:00.000Z",
      "resolvedAt": null,
      "author": {
        "email": "maya@example.com",
        "name": "Maya"
      }
    }
  ]
}

The full description, as tools/list sends it to the model:

Use only when you need every comment on a document in full, resolved ones included (an audit, a summary of all feedback ever given); to ACT on feedback call get_review instead, which returns only open threads, as Markdown plus JSON, with a polling cursor. Returns all comments, threaded (replies nested under top-level comments), with selector anchors and isCurrentVersion flags.

get_review

Read open review comments (Markdown summary + JSON). Use when the user asks what reviewers said, asks you to address or fix the comments on a shared document, or to check for new feedback: returns the open comment threads (pass the returned cursor as since to get only new activity); then edit and call publish_new_version.

When not to use it. Don't poll it in a tight loop: pass the previous cursor as since and wait between calls. Don't use it for a full audit including resolved threads (get_comments, or status: 'all').

PropertyValue
AnnotationsreadOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false
OAuth scope / key scoperead
Aliasesnone
REST equivalentGET /api/review

Inputs

ArgumentTypeRequiredDescription
slugstringyesThe document slug.
statusopen | resolved | allWhich threads to include. Default 'open' (unresolved feedback to act on).
sincestringOnly return threads with new activity after this time. Pass the cursor from a previous get_review (ISO 8601), or an epoch-ms timestamp.

Output (structuredContent; the same JSON is the last text block)

FieldTypeAlways presentDescription
slugstringyes
titlestring or null
urlstringyes
totalThreadsinteger
openThreadsintegeryes
returnedinteger
cursorstring or null
markdownstringThe same threads as a readable summary.
threadsarray of objectyes

Example call:

{
  "name": "get_review",
  "arguments": {
    "slug": "qkjgjQCQGz"
  }
}

Result:

{
  "slug": "qkjgjQCQGz",
  "title": "Pricing page v2",
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "totalThreads": 1,
  "openThreads": 1,
  "returned": 1,
  "cursor": "2026-10-27T09:12:00.000Z",
  "markdown": "# Review: Pricing page v2\n\n1 open thread.\n\n## 1. \"Monthly / Annual\" (Maya)\n\nCan the annual toggle default to on?",
  "threads": [
    {
      "id": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
      "status": "open",
      "onCurrentVersion": true,
      "comment": "Can the annual toggle default to on?",
      "markEmoji": null,
      "author": {
        "email": "maya@example.com",
        "name": "Maya"
      },
      "anchor": {
        "quote": "Monthly / Annual",
        "reproduceNote": null,
        "selector": "#billing-toggle"
      },
      "replies": []
    }
  ]
}

The full description, as tools/list sends it to the model:

Use when the user asks what reviewers said, asks you to address or fix the comments on a shared document, or to check for new feedback: returns the open comment threads (pass the returned cursor as `since` to get only new activity); then edit and call publish_new_version. It reads collaborator feedback shaped for ITERATING on a document. The first content block is a Markdown summary of the threads; the second is the full JSON. Returns OPEN comment threads by default (status: 'open' | 'resolved' | 'all'). Each thread carries a source-findable anchor — anchor.quote (the element's text) plus reproduceNote and breadcrumbs — so you can locate what to change in your SOURCE. NOTE: anchor.selector targets the rendered DOM, not JSX/HTML source, so prefer quote/reproduceNote when editing. Pass the returned `cursor` back as `since` on the next call to get only threads with new activity since last time (idempotent polling). Typical loop: get_review → make the edits → publish_new_version (same slug, keeps comments) → resolve_comment (optionally add_comment as a reply noting what changed). Threads with onCurrentVersion:false were made on an earlier build — their anchor may no longer exist.

add_comment

Comment on an element, or reply. Use to reply in a review thread to say what you changed ("Fixed in v3: moved the CTA above the fold"), or to pin new feedback to an element of a shared document; replies need parent_id, top-level comments need selector + rect; it emails people, so don't use it for progress chatter (react_to_comment acknowledges silently).

When not to use it. Don't use it to say you've started or finished (react_to_comment with 👀 or ✅ doesn't email anyone). Don't use it to close a thread: reply if useful, then resolve_comment.

PropertyValue
AnnotationsreadOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true
OAuth scope / key scopecomment
Aliasesnone
REST equivalentPOST /api/comments

Inputs

ArgumentTypeRequiredDescription
slugstringyesThe document slug.
bodystringThe comment text (max 5000 chars). May be empty for a top-level comment with mark_emoji.
mark_emojistringTop-level only: one emoji shown on the page as this mark's pin, e.g. 👍 (good), 👎 (not quite), ❤️ (love), ❓ (question), 💡 (idea), or any other emoji. Omit for the default numbered pin.
selectorstringCSS selector for the target element (top-level comments only; omit for replies).
rectobjectBounding rect of the element in pixels (top-level only). Use {x:0,y:0,w:1,h:1} as a fallback if the real rect is unknown.
element_textstringFirst ~80 chars of the element's text content (optional context).
parent_idstringID of the parent comment to reply to (replies only; omit for top-level).
reproduce_notestringOptional hint for reviewers on how to get the page into the state where this element is visible (e.g. 'Click Launch simulation'). Max 300 chars. Top-level comments only.
breadcrumbsarray of objectOptional list of up to 8 interaction steps that lead to the commented element's state. Each step has {selector: string, text: string}. Top-level comments only.
idempotency_keystringOptional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours.

Output (structuredContent; the same JSON is the last text block)

FieldTypeAlways presentDescription
idstringyes
parentIdstring or null
bodystringyes
markEmojistring or null
selectorstring or null
elementTextstring or null
createdAtstring
resolvedAtstring or null
authorobject

Example call:

{
  "name": "add_comment",
  "arguments": {
    "slug": "qkjgjQCQGz",
    "parent_id": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
    "body": "Fixed in v2: the toggle now defaults to Annual."
  }
}

Result:

{
  "id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
  "parentId": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
  "body": "Fixed in v2: the toggle now defaults to Annual.",
  "markEmoji": null,
  "selector": null,
  "elementText": null,
  "createdAt": "2026-10-27T10:02:00.000Z",
  "resolvedAt": null,
  "author": {
    "email": "agent-owner@example.com",
    "name": "Ada"
  }
}

The full description, as tools/list sends it to the model:

Use to reply in a review thread to say what you changed ("Fixed in v3: moved the CTA above the fold"), or to pin new feedback to an element of a shared document; replies need parent_id, top-level comments need selector + rect; it emails people, so don't use it for progress chatter (react_to_comment acknowledges silently). The comment is visible to the document's workspace members, and Ollav emails the document owner (and, for a reply, everyone in the thread) a notification naming this key as the agent. Top-level comments require selector + rect to anchor the pin to an element, and may set mark_emoji to show an emoji on the page instead of the numbered pin; with mark_emoji the body may be empty (a reaction pinned to the element). Replies require only parent_id and a body.

resolve_comment

Resolve or reopen a thread. Use after you've fixed what a thread asked for, typically right after publish_new_version: marks the top-level comment resolved (or with unresolve: true reopens it); only the comment's author or the document owner can, and a reply via add_comment saying what changed is good practice.

When not to use it. Don't resolve a thread you haven't addressed, and don't call it on a reply (only top-level comments resolve).

PropertyValue
AnnotationsreadOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false
OAuth scope / key scopecomment
Aliasesnone
REST equivalentPOST /api/comments/{id}/resolve, POST /api/comments/{id}/unresolve

Inputs

ArgumentTypeRequiredDescription
comment_idstringyesThe ID of the comment to resolve or unresolve.
unresolvebooleanSet to true to unresolve (re-open) an already-resolved comment.
idempotency_keystringOptional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours.

Output (structuredContent; the same JSON is the last text block)

FieldTypeAlways presentDescription
okbooleanyes
resolvedbooleanyes

Example call:

{
  "name": "resolve_comment",
  "arguments": {
    "comment_id": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f"
  }
}

Result:

{
  "ok": true,
  "resolved": true
}

The full description, as tools/list sends it to the model:

Use after you've fixed what a thread asked for, typically right after publish_new_version: marks the top-level comment resolved (or with unresolve: true reopens it); only the comment's author or the document owner can, and a reply via add_comment saying what changed is good practice. Resolve or unresolve a top-level comment by its id (from get_review or get_comments).

react_to_comment

React to a comment. Use to acknowledge a comment without a reply or email, e.g. 👀 when you start on it and ✅ when it's done (one emoji; a comment holds at most 20 different reactions).

When not to use it. Don't use it to answer a question or explain a change: reply with add_comment.

PropertyValue
AnnotationsreadOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false
OAuth scope / key scopecomment
Aliasesnone
REST equivalentPOST /api/comments/{id}/reactions

Inputs

ArgumentTypeRequiredDescription
comment_idstringyesThe ID of the comment or reply to react to.
reactionstringyesOne emoji, e.g. 👍 👀 ✅ ❤️ 🎉.
removebooleanSet to true to remove your reaction instead of adding it.
idempotency_keystringOptional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours.

Output (structuredContent; the same JSON is the last text block)

FieldTypeAlways presentDescription
okbooleanyes
commentIdstring
reactionsarray of objectyes

Example call:

{
  "name": "react_to_comment",
  "arguments": {
    "comment_id": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
    "reaction": "👀"
  }
}

Result:

{
  "ok": true,
  "commentId": "3f2b8c1e-5d4a-4e7b-9c6d-1a2b3c4d5e6f",
  "reactions": [
    {
      "reaction": "👀",
      "count": 1
    }
  ]
}

The full description, as tools/list sends it to the model:

Use to acknowledge a comment without a reply or email, e.g. 👀 when you start on it and ✅ when it's done (one emoji; a comment holds at most 20 different reactions). Add (or with remove:true, take back) your reaction to a comment or reply: any single emoji; the default bar is 👍 (agree), ❤️, 🎉, 👀 (looking at it) and ✅ (done). The old names "+1", "eyes", "check" and "heart" still work. Anyone who can comment on the document can react.

delete_comment

Delete a comment. Use only when the user asks to delete a comment: permanently deletes it and, for a top-level comment, all its replies (comment author or document owner only); to close a thread you fixed, call resolve_comment instead.

When not to use it. Don't use it to close a thread (resolve_comment keeps the history). It can't be undone.

PropertyValue
AnnotationsreadOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false
OAuth scope / key scopecomment
Aliasesnone
REST equivalentDELETE /api/comments/{id}

Inputs

ArgumentTypeRequiredDescription
comment_idstringyesThe ID of the comment to delete.
idempotency_keystringOptional. Any unique string (e.g. a UUID) for this action. If you retry the call after a timeout or error, send the same key and the same arguments: the action happens once and the retry returns its result. Keys last 24 hours.

Output (structuredContent; the same JSON is the last text block)

FieldTypeAlways presentDescription
deletedbooleanyes

Example call:

{
  "name": "delete_comment",
  "arguments": {
    "comment_id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d"
  }
}

Result:

{
  "deleted": true
}

The full description, as tools/list sends it to the model:

Use only when the user asks to delete a comment: permanently deletes it and, for a top-level comment, all its replies (comment author or document owner only); to close a thread you fixed, call resolve_comment instead. Returns {deleted:true} on success.

export_data

Export data (GDPR). Use only when the user asks for a copy or export of their data (GDPR access or portability): returns a signed link to a ZIP, valid 15 minutes, to give the user; exports are rate limited, so don't call it repeatedly. scope "account" (default): the key owner's profile, memberships, owned documents with every version, and their comments and reactions — limited to this API key's workspace. scope "workspace": everything in this key's workspace (workspace owner only).

When not to use it. Don't use it to read documents or comments (list_documents, get_review), or to back up on a schedule.

PropertyValue
AnnotationsreadOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false
OAuth scope / key scoperead
Aliasesnone
REST equivalentPOST /api/exports

Inputs

ArgumentTypeRequiredDescription
scopeaccount | workspaceWhat to export. Defaults to "account".

Output (structuredContent; the same JSON is the last text block)

FieldTypeAlways presentDescription
scopestring
downloadUrlstringyes
expiresAtintegeryes
partialboolean

Example call:

{
  "name": "export_data",
  "arguments": {
    "scope": "account"
  }
}

Result:

{
  "scope": "account",
  "downloadUrl": "https://app.ollav.app/api/exports/download?token=...",
  "expiresAt": 1793146500000,
  "partial": false
}

The full description, as tools/list sends it to the model:

Use only when the user asks for a copy or export of their data (GDPR access or portability): returns a signed link to a ZIP, valid 15 minutes, to give the user; exports are rate limited, so don't call it repeatedly. scope "account" (default): the key owner's profile, memberships, owned documents with every version, and their comments and reactions — limited to this API key's workspace. scope "workspace": everything in this key's workspace (workspace owner only). Returns {downloadUrl, expiresAt}: a signed link, valid 15 minutes, that streams the ZIP; manifest.json inside lists every item. Needs the read scope.

Errors

Every error carries the same typed error object as the REST API: type, code, message, param (when one argument is at fault), hint (what to do next, written for an agent to act on), doc_url (the code's anchor on the errors page) and, when a REST request ran, request_id.

Tool errors (the tool ran and failed) are results with isError: true. The first text block is one sentence to act on; the second is the JSON:

{
  "isError": true,
  "content": [
    { "type": "text", "text": "Ollav returned 404 NOT_FOUND: Document not found. What to do: Check the slug or id. ... Details: https://ollav.app/docs/errors#NOT_FOUND" },
    { "type": "text", "text": "{\"error\":{\"type\":\"not_found_error\",\"code\":\"NOT_FOUND\",\"message\":\"Document not found\",\"hint\":\"Check the slug or id. ...\",\"doc_url\":\"https://ollav.app/docs/errors#NOT_FOUND\",\"request_id\":\"req_6f1c2a9b0d4e8f7a1b2c3d4e\"}}" }
  ]
}

Protocol errors (no valid key, an unknown tool or method, a malformed message) are JSON-RPC errors. The error object is in error.data:

{
  "jsonrpc": "2.0",
  "id": null,
  "error": {
    "code": -32001,
    "message": "Invalid or missing Ollav API key: this key was not accepted. ...",
    "data": { "type": "authentication_error", "code": "AUTH_REQUIRED", "message": "This API key or OAuth token was not accepted.", "param": "Authorization", "hint": "The key is mistyped, revoked ...", "doc_url": "https://ollav.app/docs/errors#AUTH_REQUIRED" }
  }
}

Match on code. Don't retry invalid_request_error, authentication_error, permission_error or not_found_error unchanged; retry rate_limit_error and api_error once, after waiting, with the same idempotency_key.

Troubleshooting MCP

SymptomCodeFix
The client can't connect, or lists no toolsThe URL must be https://app.ollav.app/mcp with the Streamable HTTP transport (http, not sse). GET /mcp answers 405 on purpose: the server is stateless and only takes POST. Run the curl check under Verify the connection.
HTTP 401, JSON-RPC error -32001: "no key was sent"AUTH_REQUIREDThe client sent no credential. Check the Authorization: Bearer ol_test_... header is in the config and the environment variable it reads is set where the client runs (GUI apps don't see your shell's exports). A client that can't send headers can use https://app.ollav.app/mcp/ol_test_..., or OAuth.
HTTP 401, JSON-RPC error -32001: "this key was not accepted"AUTH_REQUIREDThe key is mistyped, revoked (rotating a key revokes the old one), or its user left the workspace. Create a new key at https://app.ollav.app/keys and update the config; an OAuth client should reconnect. Don't retry with the same key.
A tool returns 403 SCOPE_REQUIREDSCOPE_REQUIREDThe key lacks the scope that tool needs (each tool's section lists it). Create a key with upload, read and comment, or reconnect OAuth and grant them.
A tool returns 404 NOT_FOUND for a slug or comment you know existsNOT_FOUNDTest and live don't mix: a test key (ol_test_) only sees documents it published in its sandbox, and a live key never sees those. A key also only sees its own workspace. Test documents are deleted 30 days after upload. Call list_documents with the same key to see what it can reach.
A tool returns 404 ARCHIVEDARCHIVEDThe document was archived (by hand or because it expired). Restore it with POST /api/documents/{slug}/unarchive, or share it again.
A tool returns 429 RATE_LIMITEDRATE_LIMITEDEvery account may publish (share_html and publish_new_version together) 30 times an hour and post 120 comments and replies an hour, with looser limits per IP address; a test key also allows 60 tool calls a minute, and exports are limited for every key. The error's retry_after says how many seconds to wait; then retry once with the same idempotency_key. Don't retry in a loop. Live keys also follow the plan's daily limit.
Every call returns 403 ACCOUNT_SUSPENDED (HTTP 403, JSON-RPC error -32001)ACCOUNT_SUSPENDEDThe account was suspended for breaking the Terms of Service, so all of its keys and connections are refused. Stop and tell the user; another key of the same account won't work.
publish_new_version returns 410 DOCUMENT_REMOVEDDOCUMENT_REMOVEDThe document was taken down for breaking the Terms of Service; its page answers 410 and it takes no new versions. Tell the user; don't retry or re-share it.
A tool returns 402 PLAN_LIMIT or 402 PLAN_READ_ONLYPLAN_LIMITA live workspace is at (or over) a limit of its plan; limit says which. Nothing was changed. Tell the user and don't retry. Test keys never hit plan limits.
A tool returns 400 VALIDATION_ERRORVALIDATION_ERRORAn argument is missing or invalid; param names it (e.g. slug is required). Check the call against the tool's input schema (tools/list, or its section above) and call again with the argument fixed.
JSON-RPC error -32602: "Unknown tool"VALIDATION_ERRORThe tool name isn't one this server has. Call tools/list. The old names upload_document and new_version still work.
share_html returns 413 TOO_LARGE, NOT_HTML or MISSING_HTMLTOO_LARGEOne self-contained HTML file up to 15 MB per call (a multipart upload with more files is 400 TOO_MANY_FILES). Compress inlined images (WebP, downscale) and pass the page as html or file. With a shell, upload the file with curl instead.
A tool returns 409 IDEMPOTENCY_KEY_REUSEDIDEMPOTENCY_KEY_REUSEDThat idempotency_key was used in the last 24 hours with different arguments. Use a fresh key (e.g. a new UUID) for a new action; reuse a key only to retry the identical call.
A tool returns 409 IDEMPOTENCY_KEY_IN_USEIDEMPOTENCY_KEY_IN_USEThe first call with this key is still running. Wait a second and retry with the same key and arguments; you get the first call's result.
A tool returns 500 INTERNAL_ERROR or 502 PARSE_ERRORINTERNAL_ERRORSomething failed on our side. Retry once after a few seconds with the same idempotency_key; if it fails again, tell the user and quote request_id.
Results say "TEST MODE", and teammates can't open the linkYou're using a test key: only its owner sees test documents. Switch the client to a live key (ol_live_...) to share with the team.

Still stuck? Every REST response has a Request-Id header and every tool error carries request_id; quote it when you report a problem.

More