# Redpen API — Agent Reference
Redpen 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
Production: https://app.redpens.app
(Served HTML lives at: https://content.redpens.app/p/{slug})
## Authentication
All API endpoints accept an API key via the Authorization header:
Authorization: Bearer sk_live_
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.
All state-changing endpoints also require:
X-Redpen-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: "authed" (default) — sign-in required; allowed accounts or invited people only
"link" — 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.
Response 201:
{ "url": "https://content.redpens.app/p/{slug}", "slug": "...", "documentId": "...",
"versionId": "...", "hash": "...", "deduped": false, "versioned": false }
Example (small inline document):
curl -X POST https://app.redpens.app/api/documents \
-H "Authorization: Bearer sk_live_..." \
-H "X-Redpen-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.redpens.app/api/documents \
-H "Authorization: Bearer sk_live_..." \
-H "X-Redpen-Client: agent" \
-F "file=@dist/index.html"
### List your documents
GET /api/documents
Scopes required: read
Response 200: { "documents": [...] } (non-archived docs, newest first)
Example:
curl https://app.redpens.app/api/documents \
-H "Authorization: Bearer sk_live_..." \
-H "X-Redpen-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.redpens.app/api/documents/{slug}/versions \
-H "Authorization: Bearer sk_live_..." \
-H "X-Redpen-Client: agent" \
-F "file=@dist/index.html"
Example (small inline document):
curl -X POST https://app.redpens.app/api/documents/{slug}/versions \
-H "Authorization: Bearer sk_live_..." \
-H "X-Redpen-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
}
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.redpens.app/api/comments \
-H "Authorization: Bearer sk_live_..." \
-H "X-Redpen-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, gated by the parent document's visibility.
Response 201: { "id": "...", "url": "https://content.redpens.app/a/{id}[?vt=…]",
"mime": "image/png", "width": 1024, "height": 768, "size": 20481 }
For authed docs the url carries a short-lived ?vt token; for link 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.
Response 200:
{ "slug": "...", "title": "...", "url": "https://content.redpens.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",
"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
### 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": "authed" | "link" }
Switching to "link" 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.redpens.app/api/documents/{slug} \
-H "Authorization: Bearer sk_live_..." \
-H "X-Redpen-Client: agent" \
-H "Content-Type: application/json" \
-d '{"visibility":"link"}'
### 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.
## UPLOADING A BUILT APP (React/Vue/etc.) — read this before generating HTML
A bundled single-file app is megabytes. HOW it reaches Redpen decides whether that
costs nothing or costs a fortune:
BAD agent regenerates/pastes the bundle as an "html" string (JSON body, or the
MCP upload_document 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.redpens.app/api/documents \
-H "Authorization: Bearer $REDPEN_API_KEY" -H "X-Redpen-Client: agent" \
-F "file=@dist/index.html"
# 3. Iterating? New version at the SAME url, comments preserved
curl -X POST https://app.redpens.app/api/documents/$SLUG/versions \
-H "Authorization: Bearer $REDPEN_API_KEY" -H "X-Redpen-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.redpens.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": { "code": "ERROR_CODE", "message": "Human-readable message" } }
Common codes: AUTH_REQUIRED (401), SCOPE_REQUIRED (403), NOT_FOUND (404),
CSRF_REQUIRED (403), VALIDATION_ERROR (400), TOO_LARGE (413)
## Quickstart (agent headless flow)
# 1. Upload
SLUG=$(curl -s -X POST https://app.redpens.app/api/documents \
-H "Authorization: Bearer $KEY" -H "X-Redpen-Client: agent" \
-H "Content-Type: application/json" \
-d '{"html":"v1"}' | jq -r .slug)
# 2. Comment
curl -X POST https://app.redpens.app/api/comments \
-H "Authorization: Bearer $KEY" -H "X-Redpen-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.redpens.app/api/documents/$SLUG/versions \
-H "Authorization: Bearer $KEY" -H "X-Redpen-Client: agent" \
-H "Content-Type: application/json" \
-d '{"html":"v2"}'
# 4. Read feedback
curl https://app.redpens.app/api/comments?slug=$SLUG \
-H "Authorization: Bearer $KEY" -H "X-Redpen-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.redpens.app/api/review?slug=$SLUG&since=$CURSOR" \
-H "Authorization: Bearer $KEY" -H "X-Redpen-Client: agent"
## MCP connector
Redpen 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.redpens.app/mcp/sk_live_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 redpen https://app.redpens.app/mcp \
--header "Authorization: Bearer sk_live_..."
Or interactively: /mcp add
### Available MCP tools
upload_document — host HTML at a stable URL; returns the shareable URL
new_version — update content at the same URL (keeps old comments)
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
delete_comment — delete a comment and its replies (author or doc owner only)