# Ollav API — Agent Reference When to use Ollav: a user asks to share, preview, get feedback on or review an HTML file, prototype, mockup or generated page. Publish it, give them the url, read the comments back, publish a new version at the same url. Quickstart for every MCP client: https://ollav.app/docs/agents Ollav hosts HTML documents (prototypes, articles) at stable URLs and enables element-anchored threaded comments. Humans use the web UI; agents use this REST API. ## Base URL https://app.ollav.app (Served HTML lives at: https://content.ollav.app/p/{slug}) ## Authentication All API endpoints accept an API key via the Authorization header: Authorization: Bearer ol_test_ API keys have scopes: upload, read, comment. Create keys at /keys (web UI, session required). Keys are shown once at creation and stored as SHA-256 hashes. Keys come in two modes. ol_live_... keys work on the workspace's real data. ol_test_... keys work in the key owner's own TEST sandbox of that workspace: they can't see live documents (and live keys can't see test ones), nothing counts towards the plan (each test key may make 60 requests a minute), test documents are marked TEST and deleted 30 days after upload. GET /api/me returns "livemode": false for a test key, and uploads return "livemode". The examples here use a test key; switch to a live key to share with the team. Keys made before test mode start sk_live_ and are live keys. Each key belongs to ONE workspace — the one that was current when it was made. Everything it uploads lands there, list endpoints show only that workspace, and every other workspace's documents answer 404 (even ones the key's user can reach from the browser). If its user leaves the workspace, the key stops working. ## Workspaces and roles Documents, comments, attachments, snapshots and API keys all belong to a workspace. Every user has a personal workspace and can create more (/workspace). Roles: owner > admin > member > guest. guest view, comment on and react to the documents shared with them — nothing else member + every document in the workspace; upload documents and versions admin + invite and remove members; manage any document in the workspace owner + invite and remove admins Comments are always members-only, whatever a document's visibility (a guest counts only on the documents shared with them). Sign-up: SIGNUP_MODE=open lets any Google account or magic-link email sign up; the default (invite-only) needs an invite, a shareable invite link or a verified domain. New accounts agree to the Terms and Privacy Policy on /welcome before their first session, and again whenever either version changes. Workspace endpoints (session sign-in; API keys only read their own workspace): GET /api/workspaces your workspaces and the current one POST /api/workspaces { "name" } — create; you become owner POST /api/workspaces/current { "workspaceId" } — switch GET /api/workspaces/{id}/members members (+ pending invites for admins) POST /api/workspaces/{id}/invites { "email", "role"?, "expiresInDays"? } — single use, only for that email; or { "link": true, "role"?, "maxUses"? = 25, "expiresInDays"? = 14 } → { "inviteUrl", "emailed" } — copy the link whenever emailed is false DELETE /api/workspaces/{id}/invites/{inviteId} revoke an invite POST /api/documents/{slug}/share { "email" } or { "link": true, "maxUses"? } — share one document with outside reviewers (they become guests of just that document) GET /api/documents/{slug}/share its guests and live share links DELETE /api/documents/{slug}/guests/{userId} stop sharing with a guest GET|POST /api/workspaces/{id}/domains claimed email domains (plan feature, admins); POST /api/workspaces/{id}/domains/{did}/verify checks _ollav-challenge.{domain} TXT DELETE /api/workspaces/{id}/members/{userId} remove a member (or yourself, to leave) All state-changing endpoints also require: X-Ollav-Client: agent This header is the CSRF guard. Always include it. ## Endpoints ### Upload a document POST /api/documents Scopes required: upload Content-Type: application/json (or multipart/form-data, or text/html) JSON body: { "html": "...", "title": "My Doc" } visibility: "workspace" (default) — members of the key's workspace only "link" — anyone signed in to Ollav who has the URL (members comment) "public" — PUBLIC: anyone with the URL, no sign-in, INCLUDING people outside the company. The slug is unguessable, but there is no access check. Only use for deliberate external sharing. ("authed", the pre-workspace default, is accepted as "workspace".) Response 201: { "url": "https://content.ollav.app/p/{slug}", "slug": "...", "documentId": "...", "versionId": "...", "hash": "...", "deduped": false, "versioned": false } Example (small inline document): curl -X POST https://app.ollav.app/api/documents \ -H "Authorization: Bearer ol_test_..." \ -H "X-Ollav-Client: agent" \ -H "Content-Type: application/json" \ -d '{"html":"Hello","title":"Test"}' Example (a file on disk — ALWAYS use this for built apps, see "Uploading a built app" below; curl reads the file, so an agent spends ZERO tokens on it): curl -X POST https://app.ollav.app/api/documents \ -H "Authorization: Bearer ol_test_..." \ -H "X-Ollav-Client: agent" \ -F "file=@dist/index.html" ### List your documents GET /api/documents Scopes required: read Response 200: [ {...document, "url": "..."} ] (a bare array; non-archived docs, newest first) Example: curl https://app.ollav.app/api/documents \ -H "Authorization: Bearer ol_test_..." \ -H "X-Ollav-Client: agent" ### Get a single document GET /api/documents/{slug} Scopes required: read Response 200: document object with versions array ### Add a new version POST /api/documents/{slug}/versions Scopes required: upload Content-Type: application/json Body: { "html": "...", "title": "optional" } Response 201: { "slug": "...", "versionId": "...", "hash": "...", "deduped": false } Example (file on disk — preferred when iterating on a build): curl -X POST https://app.ollav.app/api/documents/{slug}/versions \ -H "Authorization: Bearer ol_test_..." \ -H "X-Ollav-Client: agent" \ -F "file=@dist/index.html" Example (small inline document): curl -X POST https://app.ollav.app/api/documents/{slug}/versions \ -H "Authorization: Bearer ol_test_..." \ -H "X-Ollav-Client: agent" \ -H "Content-Type: application/json" \ -d '{"html":"Updated"}' ### List versions GET /api/documents/{slug}/versions Scopes required: read Response 200: { "document": {...}, "versions": [...] } ### Post a comment POST /api/comments Scopes required: comment Content-Type: application/json Body (top-level): { "slug": "...", "body": "Great prototype!", "selector": "h1", "rect": {"x":0,"y":0,"w":100,"h":40}, "elementText": "Page title", "reproduceNote": "Click 'Launch simulation' first", // optional, ≤300 chars "breadcrumbs": [{"selector":"button#launch","text":"Launch simulation"}], // optional, ≤8 items "markEmoji": "💡" // optional: show this emoji on the page instead of the numbered pin } Emoji mark with no text (a reaction pinned to an element): { "slug": "...", "selector": "h1", "rect": {...}, "markEmoji": "❤️" } Body (reply): { "slug": "...", "body": "Agreed!", "parentId": "" } reproduceNote: optional hint for reviewers on how to reproduce the UI state. breadcrumbs: optional array of {selector, text} interaction steps. Top-level only. Both fields are silently ignored for replies. Response 201: comment object (includes reproduceNote, breadcrumbs parsed) Example (top-level with reproduce info): curl -X POST https://app.ollav.app/api/comments \ -H "Authorization: Bearer ol_test_..." \ -H "X-Ollav-Client: agent" \ -H "Content-Type: application/json" \ -d '{"slug":"abc123","body":"Looks good","selector":"body","rect":{"x":0,"y":0,"w":1,"h":1},"reproduceNote":"Open modal first"}' ### Attach an image to a comment POST /api/comments/{id}/attachments Scopes required: comment Content-Type: multipart/form-data (field 'file') or a raw image body One image per TOP-LEVEL comment (replies cannot have attachments). Image type is validated by magic bytes (png/jpeg/webp/gif — SVG rejected); max 5 MB. Optional 'width'/'height' form fields are layout hints. Stored content-addressed in R2 and served from the content origin: open for public docs, otherwise only to current members of the document's workspace. Response 201: { "id": "...", "url": "https://content.ollav.app/a/{id}[?vt=…]", "mime": "image/png", "width": 1024, "height": 768, "size": 20481 } For non-public docs the url carries a short-lived ?vt token naming you; for public docs it is open. ### List comments GET /api/comments?slug={slug} Scopes required: read Each top-level comment includes "attachment": {url, mime, width, height} | null. Response 200: { "slug": "...", "currentVersionId": "...", "count": N, "unresolvedCount": N, "comments": [ { ...comment, "reproduceNote": "..." | null, "breadcrumbs": [{selector,text}] | null, "replies": [...] } ] } ### Read feedback for iteration (agent loop) GET /api/review?slug={slug} Scopes required: read An agent-shaped digest of feedback, leaner and more actionable than GET /api/comments. Query params: status open (default) | resolved | all since ISO 8601 or epoch-ms; return only threads with NEW activity after this. Pass the response's "cursor" back here next time for idempotent polling. format json (default) | markdown — markdown returns a readable summary (text/markdown) of the same threads, ready to paste into a prompt. The JSON response always carries it too, as "markdown". Response 200: { "slug": "...", "title": "...", "url": "https://content.ollav.app/p/{slug}", "currentVersionId": "...", "filter": {"status":"open","since":null}, "totalThreads": N, "openThreads": N, "returned": N, "cursor": "" | null, // pass as ?since= next call "generatedAt": "", "note": "anchor.selector is rendered DOM; prefer quote/reproduceNote for source", "threads": [ { "id": "...", "status": "open", "onCurrentVersion": true, "versionId": "...", "author": {"email":"...","name":"..."}, "createdAt": "", "lastActivityAt": "", "comment": "the feedback text ("" for an emoji-only mark)", "markEmoji": "💡"|null, "attachment": {"url":"…/a/{id}","mime":"image/png","width":N,"height":N} | null, "anchor": { "quote": "", "reproduceNote": "..."|null, "breadcrumbs": [...]|null, "selector": "" }, "replies": [ {"author":{...},"body":"...","createdAt":""} ] } ] } onCurrentVersion:false means the thread was placed on an earlier build — its anchor may no longer exist in the current version. ### Resolve a comment POST /api/comments/{id}/resolve Scopes required: comment ### Unresolve a comment POST /api/comments/{id}/unresolve Scopes required: comment ### React to a comment POST /api/comments/{id}/reactions Scopes required: comment Body: { "reaction": "👍", "on": true } (on:false removes it) Any single emoji. The default bar is 👍 ❤️ 🎉 👀 ✅; the old names "+1", "eyes", "check" and "heart" still work. At most 20 different reactions per comment. ### Delete a comment DELETE /api/comments/{id} Scopes required: comment Allowed for the comment's own author or the document owner. Top-level comments also delete all their replies. Returns { "deleted": true } on success. Returns 404 for unknown id or unauthorized callers (no existence leak). ### Dashboard (aggregate) GET /api/dashboard Scopes required: read Response 200: { "documents": [ { ...doc, versionCount, commentCount, unresolvedCount } ] } ### Archive a document POST /api/documents/{slug}/archive Scopes required: upload Archived documents return HTTP 410. Reversible via /unarchive. ### Unarchive a document POST /api/documents/{slug}/unarchive Scopes required: upload ### Update visibility PATCH /api/documents/{slug} Scopes required: upload Body: { "visibility": "workspace" | "link" | "public" } Switching to "public" makes the document PUBLIC — anyone with the URL, no sign-in, including people outside the company. Do this only when the person you are working for has asked for external sharing, and tell them you did. Example (deliberate external share): curl -X PATCH https://app.ollav.app/api/documents/{slug} \ -H "Authorization: Bearer ol_test_..." \ -H "X-Ollav-Client: agent" \ -H "Content-Type: application/json" \ -d '{"visibility":"public"}' ### Set/extend expiry POST /api/documents/{slug}/expiry Scopes required: upload Body: { "action": "extend" } — adds 90 days from now { "action": "permanent" } — removes expiry { "action": "custom", "expiresAt": } ### Export document data GET /api/documents/{slug}/export Scopes required: read Returns full JSON bundle: document metadata + all versions (HTML inlined) + all comments/threads. ### Export your data (ZIP) — MCP: export_data POST /api/exports Scopes required: read Body: { "scope": "account" } — your profile, memberships, the documents you own with every version, your comments and reactions (an API key: its workspace only) { "scope": "workspace" } — everything in the key's workspace (owner only) Returns: { "downloadUrl": "...", "expiresAt": } GET the downloadUrl (no auth header needed, valid 15 minutes) for a ZIP; manifest.json inside lists every item and file. Deleting a document, a workspace or an account needs a signed-in session in the web UI (an API key can't), and asks you to type its name. ## UPLOADING A BUILT APP (React/Vue/etc.) — read this before generating HTML A bundled single-file app is megabytes. HOW it reaches Ollav decides whether that costs nothing or costs a fortune: BAD agent regenerates/pastes the bundle as an "html" string (JSON body, or the MCP share_html tool). Tool args are MODEL OUTPUT — every character is generated tokens. A 6MB build is slow, expensive, and frequently exceeds the model's per-response output limit, so it cannot be produced at all. GOOD the BUNDLER writes one self-contained file to disk, then curl uploads that file. curl reads it from disk, so the agent spends ZERO tokens on content. # 1. Build one self-contained file (any bundler: vite-plugin-singlefile, webpack, …) npm run build # -> dist/index.html with CSS/JS/assets inlined # 2. Upload the FILE (new document) 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" # 3. Iterating? New version at the SAME url, comments preserved 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" Raw-body form works too: -H "Content-Type: text/html" --data-binary @dist/index.html EXCEPTION — no shell or no network egress (claude.ai / Cowork sandboxes): the MCP tools are the only channel there, so a large build genuinely cannot be uploaded by the agent. Build it, then upload the file by hand at https://app.ollav.app. ## LIMITATIONS - One self-contained HTML file per upload, maximum 15 MB. - Relative asset paths (src="assets/app.js", href="styles.css", src="img/logo.png") WILL NOT load — there is no multi-file hosting. Use absolute URLs (https://…), data: URIs, or inline the assets. Tools like vite-plugin-singlefile can bundle a project into one inlined file. - No server-side code — static serving only. Client-side JS calling external CORS-friendly APIs works fine. - SPA history routing: /p/{slug}/* sub-paths all serve the same document, so deep links and reloads work. The app's BrowserRouter determines whether the exact deep route is restored (set basename appropriately in your Vite app). - The upload and new-version endpoints return an advisory warnings[] array in the response body when relative asset references or a tag are detected. Uploads are never blocked — warnings are informational only. ## Error shape { "error": { "type": "invalid_request_error", "code": "ERROR_CODE", "message": "Human-readable message", "param": "field (when one is at fault)", "hint": "What to do next", "doc_url": "https://ollav.app/docs/errors#ERROR_CODE", "request_id": "req_...", "docs": "https://ollav.app/docs/agents#errors" } } Match on code (stable; every code is on https://ollav.app/docs/errors). Read hint before retrying. type is one of invalid_request_error, authentication_error, permission_error, not_found_error, rate_limit_error, idempotency_error, api_error. Every response has a Request-Id header (= error.request_id): quote it when reporting a problem. Common codes: AUTH_REQUIRED (401), SCOPE_REQUIRED (403), NOT_FOUND (404), CSRF_REQUIRED (403), VALIDATION_ERROR (400), TOO_LARGE (413), PLAN_LIMIT / PLAN_READ_ONLY (402), IDEMPOTENCY_KEY_REUSED (409) ## Safe retries (Idempotency-Key) Send Idempotency-Key: on any POST, PATCH or DELETE. A retry with the same key and the same request returns the first response (header Idempotent-Replayed: true) instead of acting twice. Same key, different request: 409 IDEMPOTENCY_KEY_REUSED. Still running: 409 IDEMPOTENCY_KEY_IN_USE. Keys last 24 hours, per API key. MCP tools that change something take an idempotency_key argument. ## API versions (Ollav-Version) Breaking changes ship as dated versions. A request runs at the Ollav-Version header's version, else at the version the API key was pinned to when created (GET /api/keys → apiVersion). Responses carry the version used in an Ollav-Version header. Versions and changes: https://ollav.app/docs/changelog (Atom: https://ollav.app/docs/changelog.atom). ## Plans and limits (402) A workspace's plan (Free, Pro, Team — https://app.ollav.app/pricing) limits documents, members, storage, version history and API calls per day (UTC; every API-key request, REST or MCP, counts one). A write that would cross a limit returns 402: { "error": { "code": "PLAN_LIMIT", "message": "...", "limit": "documents" | "members" | "storageBytes" | "apiCallsPerDay", "plan": "free", "upgradeUrl": "...", "pricingUrl": "..." } } PLAN_READ_ONLY (402): the workspace is already over a limit (after a downgrade or a failed payment). Reading, exporting, archiving and deleting still work; uploads, versions, comments and reactions don't until the workspace is back within its limits. Don't retry a 402: tell the user. (REST bodies carry upgradeUrl for the web UI; MCP tool results state the limit only.) GET /api/workspaces/{id}/billing returns the plan, limits and usage. ## Quickstart (agent headless flow) # 1. Upload SLUG=$(curl -s -X POST https://app.ollav.app/api/documents \ -H "Authorization: Bearer $KEY" -H "X-Ollav-Client: agent" \ -H "Content-Type: application/json" \ -d '{"html":"v1"}' | jq -r .slug) # 2. Comment curl -X POST https://app.ollav.app/api/comments \ -H "Authorization: Bearer $KEY" -H "X-Ollav-Client: agent" \ -H "Content-Type: application/json" \ -d "{"slug":"$SLUG","body":"Feedback here","selector":"body","rect":{"x":0,"y":0,"w":1,"h":1}}" # 3. New version curl -X POST https://app.ollav.app/api/documents/$SLUG/versions \ -H "Authorization: Bearer $KEY" -H "X-Ollav-Client: agent" \ -H "Content-Type: application/json" \ -d '{"html":"v2"}' # 4. Read feedback curl https://app.ollav.app/api/comments?slug=$SLUG \ -H "Authorization: Bearer $KEY" -H "X-Ollav-Client: agent" ## Feedback loop (iterate on comments) The intended cycle for acting on human feedback with an agent: 1. READ GET /api/review?slug=$SLUG → open threads + a "cursor" 2. EDIT (regenerate the HTML from the feedback; use anchor.quote / anchor.reproduceNote to find each element in your SOURCE — the selector is rendered-DOM, not JSX) 3. UPDATE POST /api/documents/$SLUG/versions → new build at the SAME url 4. CLOSE POST /api/comments/$ID/resolve (optionally POST /api/comments with parentId to reply "fixed in v3 — …") 5. POLL GET /api/review?slug=$SLUG&since=$CURSOR → only NEW activity Because `since` is the high-water cursor from the previous call, step 5 is idempotent — re-running it returns nothing until a new comment or reply lands. This maps cleanly onto a polling driver (e.g. a scheduled task or Claude Code's /loop): re-run get_review on an interval, and only act when `returned` > 0. No always-on server is required — the agent is woken or polls, it does not sit on a socket. (A push/webhook trigger is not offered yet; polling covers it.) # Read only what changed since your last build curl "https://app.ollav.app/api/review?slug=$SLUG&since=$CURSOR" \ -H "Authorization: Bearer $KEY" -H "X-Ollav-Client: agent" ## MCP connector Ollav exposes a native MCP (Model Context Protocol) endpoint for claude.ai, Cowork sandboxes, and Claude Code — clients that cannot make direct egress POSTs but whose MCP traffic routes through Anthropic. Transport: Streamable HTTP (MCP spec 2025-03-26), stateless, JSON responses. ### claude.ai / Cowork setup Settings → Connectors → Add custom connector URL: https://app.ollav.app/mcp/ol_test_YOURKEY IMPORTANT: the URL contains your API key — treat it as a secret. Revoke it at /keys if it is ever exposed. The path-key form exists because claude.ai connectors cannot set custom HTTP headers. ### Claude Code claude mcp add --transport http ollav https://app.ollav.app/mcp \ --header "Authorization: Bearer ol_test_..." Or interactively: /mcp add ### Available MCP tools share_html — host HTML at a stable URL; returns the shareable URL (alias: upload_document) publish_new_version — update content at the same URL, keeps comments (alias: new_version) list_documents — dashboard aggregate (version + comment counts) get_document — metadata + version history for one document get_comments — threaded comments with selector anchors and isCurrentVersion flags get_review — agent-shaped feedback digest (open threads, source-findable anchors, polling cursor) add_comment — pin an element-anchored comment (or reply to one) resolve_comment — resolve or unresolve a comment thread react_to_comment — add or remove an emoji reaction (👍 ❤️ 🎉 👀 ✅ or any emoji) on a comment delete_comment — delete a comment and its replies (author or doc owner only) export_data — signed link to a ZIP of your data (GDPR export)