OpenTree DashboardSign in

$ man opentree

Docs

Everything the server exposes: three ways to authenticate, twelve MCP tools, the whole REST surface, the upload formats it accepts, and the defaults it applies to every page it serves.

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

And 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.html

Per-client instructions: Claude Code, Codex, Cursor, the Claude.ai connector, Slack.

Authentication

Three ways in, all landing on the same account:

MethodHeader / flowUse it for
API keyAuthorization: Bearer otr_live_…Scripts, CI, curl, any MCP client that lets you set a header.
OAuth 2.1/oauth/authorize/oauth/tokenHosted clients that register themselves. Authorization code + PKCE, dynamic registration at /oauth/register, revocation at /oauth/revoke.
Browser sessionGitHub sign-in or an emailed one-time linkThe 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_TOKEN

MCP 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:

ToolWhat it does
publish_htmlPublish a document and get back an unlisted URL.
update_siteReplace the content behind an existing URL, in place.
get_siteRead one site's settings, counters and share URL.
list_sitesList everything the calling key owns.
delete_siteHard-delete the record and the stored bytes.
set_passwordPut a passphrase in front of the page, or clear it.
set_expiryMove the expiry, or pin the link open forever.
set_email_gateRestrict opens to one work-email domain.
set_agentationTurn the on-page toolbar (notes, reactions, feedback) on or off.
list_feedbackRead what viewers left, newest first.
resolve_feedbackMark one piece of feedback handled.
link_walletAttach 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.

MethodPathDescription
POST/sitesPublish. Accepts JSON, a bare file body, or multipart/form-data.
GET/sitesList sites owned by the key.
GET/sites/:idOrSlugOne site: settings, counters, share URL, preview URL.
GET/sites/:idOrSlug/contentThe stored document exactly as it will be served.
PUT/sites/:idOrSlugReplace the content. Same URL, new bytes.
PATCH/sites/:idOrSlugChange settings only — content is left untouched.
DELETE/sites/:idOrSlugDelete the record and purge the object storage.
GET/sites/:idOrSlug/engagementOpens, unique viewers, dwell, scroll depth, read-to-end.
GET/sites/:idOrSlug/reactionsAggregated reactions for one page.
GET/sites/:idOrSlug/feedbackViewer notes and comments, with resolve state.
POST/feedback/:id/resolveMark one item resolved.
DELETE/feedback/:idDelete one item outright.
POST/v1/publishAnonymous publish. No key, short-lived link, rate-limited by IP.
GET/raw/:tokenThe page as plain text, for an agent to re-read.
POST/share-tokensMint an extra, separately revocable link to the same page.
DELETE/share-tokens/:idRevoke one share link without touching the others.
GET/api-keysList keys. Create with POST, revoke with DELETE.
POST/custom-domainsRegister a hostname; returns the CNAME and TXT records to add.
POST/custom-domains/:hostname/verifyCheck DNS and issue the certificate.
GET/activityAccount-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
FieldTypeNotes
file / contentfile or stringRequired. The document itself.
formatstringhtml, markdown, svg, zip. Inferred from the filename or content type when omitted.
titlestringOptional. Otherwise read from the document's own <title> or first heading.
expires_in_hoursnumber or neverDefaults to 168 hours. never pins the link open.
passwordstringGate the page immediately, without a second call.
allowed_email_domainstringOnly addresses at this domain can request an open link.
burn_after_readbooleanThe first successful open is the last one.
pii_checkblock / warn / offSensitive-data scan mode. Blocking is the default.
agentationbooleanInject the on-page toolbar for notes and comments.
reactionsbooleanIndependent switch from agentation.
csp_strictbooleanStrict 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

SettingValue
Default expiry, keyed publish168 hours (7 days)
Default expiry, anonymous publish24 hours
Maximum expirynever — there is no ceiling on this deployment
Sites per accountUnlimited
Upload sizeSet by MAX_UPLOAD_BYTES in wrangler.toml
Anonymous publishesRate-limited per IP, fixed window
Custom domainsUnlimited
SeatsUnlimited — there is no per-seat charge
This is a self-hosted deployment. Nothing above is a plan boundary you can hit and have to pay through; they are variables in a config file you control. Self-host explains the deployment, pricing explains why the table is empty.

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

The 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:

StatusSlugCause
400unsupported_content_typeA body the server does not accept; the reply lists what it does.
401unauthorizedMissing, malformed or revoked credential.
403pii_detectedThe scan found sensitive data. Re-send with pii_check set to warn.
404not_foundNo such site — or the token is wrong.
410goneExpired, burned after reading, or deleted.
429rate_limitedToo many anonymous publishes from one address.