Getting started
Publishing needs a key. Create one in the dashboard — keys are shown once,
carry the prefix otr_live_, and can be revoked individually without disturbing the
others. Then point an agent at the server:
claude mcp add --transport http opentree https://read.botook.ai/mcp \
--header "Authorization: Bearer otr_live_YOUR_KEY"or, for a client that reads a config file:
{
"mcpServers": {
"opentree": {
"type": "http",
"url": "https://read.botook.ai/mcp",
"headers": { "Authorization": "Bearer otr_live_YOUR_KEY" }
}
}
}No agent involved? The same publish is one HTTP call:
curl -X POST https://read.botook.ai/sites \
-H "Authorization: Bearer otr_live_YOUR_KEY" \
-F file=@index.htmlAnd with no key at all — the anonymous route, rate-limited and short-lived:
curl -X POST https://read.botook.ai/v1/publish -F file=@index.htmlPer-client instructions: Claude Code, Codex, Cursor, the Claude.ai connector, Slack.
Authentication
Three ways in, all landing on the same account:
| Method | Header / flow | Use it for |
|---|---|---|
| API key | Authorization: Bearer otr_live_… | Scripts, CI, curl, any MCP client that lets you set a header. |
| OAuth 2.1 | /oauth/authorize → /oauth/token | Hosted clients that register themselves. Authorization code + PKCE, dynamic registration at /oauth/register, revocation at /oauth/revoke. |
| Browser session | GitHub sign-in or an emailed one-time link | The dashboard. Never used by the API. |
Tokens issued by the OAuth flow are signed HS256 and live 30 days. Revoking one
kills the refresh token with it — a revoked grant cannot be traded back into a fresh access token.
Discovery documents sit at /.well-known/oauth-authorization-server and
/.well-known/oauth-protected-resource.
curl -X POST https://read.botook.ai/oauth/revoke \
-d token=YOUR_ACCESS_OR_REFRESH_TOKENMCP server
Endpoint: https://read.botook.ai/mcp, Streamable HTTP. An unauthenticated request answers
401 with a WWW-Authenticate header pointing at the authorization server,
which is what lets a connector bootstrap itself. Twelve tools:
| Tool | What it does |
|---|---|
publish_html | Publish a document and get back an unlisted URL. |
update_site | Replace the content behind an existing URL, in place. |
get_site | Read one site's settings, counters and share URL. |
list_sites | List everything the calling key owns. |
delete_site | Hard-delete the record and the stored bytes. |
set_password | Put a passphrase in front of the page, or clear it. |
set_expiry | Move the expiry, or pin the link open forever. |
set_email_gate | Restrict opens to one work-email domain. |
set_agentation | Turn the on-page toolbar (notes, reactions, feedback) on or off. |
list_feedback | Read what viewers left, newest first. |
resolve_feedback | Mark one piece of feedback handled. |
link_wallet | Attach a wallet identity to the account that owns a page. |
A minimal call, hand-rolled over HTTP:
curl -X POST https://read.botook.ai/mcp \
-H "Authorization: Bearer otr_live_YOUR_KEY" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"publish_html",
"arguments":{"content":"<h1>hi</h1>","expires_in_hours":24}}}'Machine-readable descriptions of the same surface:
/.well-known/mcp/server-card.json, /.well-known/ai-catalog.json,
/openapi.json and /llms.txt.
HTTP API
Every endpoint below is reachable both bare (/sites) and under the
/api prefix (/api/sites) — an agent that guessed either one from the
docs lands in the same place.
| Method | Path | Description |
|---|---|---|
POST | /sites | Publish. Accepts JSON, a bare file body, or multipart/form-data. |
GET | /sites | List sites owned by the key. |
GET | /sites/:idOrSlug | One site: settings, counters, share URL, preview URL. |
GET | /sites/:idOrSlug/content | The stored document exactly as it will be served. |
PUT | /sites/:idOrSlug | Replace the content. Same URL, new bytes. |
PATCH | /sites/:idOrSlug | Change settings only — content is left untouched. |
DELETE | /sites/:idOrSlug | Delete the record and purge the object storage. |
GET | /sites/:idOrSlug/engagement | Opens, unique viewers, dwell, scroll depth, read-to-end. |
GET | /sites/:idOrSlug/reactions | Aggregated reactions for one page. |
GET | /sites/:idOrSlug/feedback | Viewer notes and comments, with resolve state. |
POST | /feedback/:id/resolve | Mark one item resolved. |
DELETE | /feedback/:id | Delete one item outright. |
POST | /v1/publish | Anonymous publish. No key, short-lived link, rate-limited by IP. |
GET | /raw/:token | The page as plain text, for an agent to re-read. |
POST | /share-tokens | Mint an extra, separately revocable link to the same page. |
DELETE | /share-tokens/:id | Revoke one share link without touching the others. |
GET | /api-keys | List keys. Create with POST, revoke with DELETE. |
POST | /custom-domains | Register a hostname; returns the CNAME and TXT records to add. |
POST | /custom-domains/:hostname/verify | Check DNS and issue the certificate. |
GET | /activity | Account-wide activity feed: opens, reactions, feedback. |
POST /sites
Upload one HTML or markdown file, an SVG, or a zip of a whole directory. Three encodings are accepted and they behave identically:
# 1. multipart, the file as a file
curl -X POST https://read.botook.ai/sites -H "Authorization: Bearer otr_live_KEY" -F file=@report.md
# 2. JSON envelope
curl -X POST https://read.botook.ai/sites -H "Authorization: Bearer otr_live_KEY" \
-H "content-type: application/json" \
-d '{"content":"# Report","format":"markdown","expires_in_hours":168}'
# 3. the file *is* the body
curl -X POST https://read.botook.ai/sites -H "Authorization: Bearer otr_live_KEY" \
-H "content-type: text/markdown" --data-binary @report.md| Field | Type | Notes |
|---|---|---|
file / content | file or string | Required. The document itself. |
format | string | html, markdown, svg, zip. Inferred from the filename or content type when omitted. |
title | string | Optional. Otherwise read from the document's own <title> or first heading. |
expires_in_hours | number or never | Defaults to 168 hours. never pins the link open. |
password | string | Gate the page immediately, without a second call. |
allowed_email_domain | string | Only addresses at this domain can request an open link. |
burn_after_read | boolean | The first successful open is the last one. |
pii_check | block / warn / off | Sensitive-data scan mode. Blocking is the default. |
agentation | boolean | Inject the on-page toolbar for notes and comments. |
reactions | boolean | Independent switch from agentation. |
csp_strict | boolean | Strict content policy on the viewer. On by default. |
PUT vs PATCH
PUT /sites/:idOrSlug replaces the bytes and keeps the URL. PATCH
changes settings — expiry, password, gates, toolbar — and never touches what is stored. That split
matters in a loop: an agent tightening access should not have to re-upload the page to do it.
curl -X PATCH https://read.botook.ai/sites/SITE_ID \
-H "Authorization: Bearer otr_live_KEY" \
-H "content-type: application/json" \
-d '{"expires_at": null, "password": "hunter2"}'List, raw and delete
curl https://read.botook.ai/sites -H "Authorization: Bearer otr_live_KEY"
curl https://read.botook.ai/raw/UNLISTED_TOKEN
curl -X DELETE https://read.botook.ai/sites/SITE_ID -H "Authorization: Bearer otr_live_KEY"/raw/:token answers text/plain: the page's own text with the markup
stripped out, so a second agent can re-read a page without parsing HTML. DELETE is a
hard delete — the database row and the stored object both go.
Share tokens
One page can have several links. Each carries its own label, its own counters and its own revocation, so you can hand one to a client and one to a review channel and cut either off alone.
curl -X POST https://read.botook.ai/share-tokens \
-H "Authorization: Bearer otr_live_KEY" \
-H "content-type: application/json" \
-d '{"site_id":"SITE_ID","label":"client-preview","max_views":5}'API keys
curl -X POST https://read.botook.ai/api-keys -H "Authorization: Bearer otr_live_KEY" \
-H "content-type: application/json" -d '{"name":"ci"}'The response contains the only copy of the secret. Only a hash is stored, so a lost key is replaced, never recovered.
Custom domains
Register a hostname, add the two records it hands back, then ask it to verify. Verification really checks DNS — an unconfigured hostname is refused rather than optimistically accepted.
curl -X POST https://read.botook.ai/custom-domains \
-H "Authorization: Bearer otr_live_KEY" \
-H "content-type: application/json" \
-d '{"hostname":"share.example.com","site_id":"SITE_ID"}'
curl -X POST https://read.botook.ai/custom-domains/share.example.com/verify \
-H "Authorization: Bearer otr_live_KEY"Full walkthrough on the custom domains page.
Engagement and feedback
curl https://read.botook.ai/sites/SITE_ID/engagement -H "Authorization: Bearer otr_live_KEY"
curl https://read.botook.ai/sites/SITE_ID/feedback -H "Authorization: Bearer otr_live_KEY"
curl -X POST https://read.botook.ai/feedback/FEEDBACK_ID/resolve -H "Authorization: Bearer otr_live_KEY"engagement and analytics are the same document under two names.
It reports opens, unique viewers, dwell time, maximum scroll depth, whether anyone reached the end,
and a ten-segment map of which parts of the page held attention. It records no keystrokes, no mouse
paths and no session replay — see how pages get read.
Limits and defaults
| Setting | Value |
|---|---|
| Default expiry, keyed publish | 168 hours (7 days) |
| Default expiry, anonymous publish | 24 hours |
| Maximum expiry | never — there is no ceiling on this deployment |
| Sites per account | Unlimited |
| Upload size | Set by MAX_UPLOAD_BYTES in wrangler.toml |
| Anonymous publishes | Rate-limited per IP, fixed window |
| Custom domains | Unlimited |
| Seats | Unlimited — there is no per-seat charge |
Reading a page back
Two endpoints answer "what is actually stored", and they answer different questions.
/sites/:idOrSlug/content returns the document byte-for-byte as it will be served —
that is the one to use for backups and for checking that a conversion did what you expected.
/raw/:token returns the page as plain text with the markup stripped, which is what a
second agent usually wants when it needs to re-read the content rather than re-host it:
curl -s https://read.botook.ai/sites/SITE_ID/content \
-H "Authorization: Bearer otr_live_YOUR_KEY" -o stored.html
curl -s https://read.botook.ai/raw/UNLISTED_TOKEN -o page.txtThe content endpoint needs the owning key; /raw/:token needs only the token and
goes through exactly the same checks as the viewer page — an expired link is 410, a
gated one is 401, and a page cannot be read in plain text to get around a password.
It counts as an open, too: pointing /raw at a one-time link spends it, which is correct
and occasionally surprising. An end-to-end encrypted page answers 409, because the
server holds only ciphertext and has no text to give.
Errors
Failures return JSON with a stable error slug and a human
message. The ones you will actually meet:
| Status | Slug | Cause |
|---|---|---|
400 | unsupported_content_type | A body the server does not accept; the reply lists what it does. |
401 | unauthorized | Missing, malformed or revoked credential. |
403 | pii_detected | The scan found sensitive data. Re-send with pii_check set to warn. |
404 | not_found | No such site — or the token is wrong. |
410 | gone | Expired, burned after reading, or deleted. |
429 | rate_limited | Too many anonymous publishes from one address. |