Install
Register the server once, in the project you are already working in:
claude mcp add --transport http opentree https://read.botook.ai/mcp \
--header "Authorization: Bearer otr_live_YOUR_KEY"The key comes from the dashboard and starts with
otr_live_. Prefer a local process to an HTTP header? The same server runs over
stdio:
npx -y opentree-mcpCheck it took:
claude mcp listPublish
Ask for the page, then ask for the link. The tool call the model makes is this one:
publish_html({
content: "<!doctype html><title>Q3 review</title>…",
expires_in_hours: 168,
password: "optional",
agentation: true
})What comes back is the site id and a URL carrying a 22-character random token. That is the whole deploy: no build, no DNS, no CI. The page title is read out of the document, so you do not have to name anything.
Replace in place, every revision
This is the part that matters in a coding session. The second version of a page is not a new
link — update_site writes the new bytes behind the same token, so the address you
already pasted into a chat keeps resolving to the current build:
curl -X PUT https://read.botook.ai/sites/SITE_ID \
-H "Authorization: Bearer otr_live_YOUR_KEY" \
-F file=@dist/index.htmlWire that into the loop and the output of every run lands at one stable address — see agent-loop hosting.
A build directory, not one file
Zip the build output and publish the archive; the whole tree is served, with
index.html as the entry:
cd dist && zip -r ../site.zip . && cd ..
curl -X POST https://read.botook.ai/sites \
-H "Authorization: Bearer otr_live_YOUR_KEY" \
-F file=@site.zipMarkdown straight out of the session
A design doc or a postmortem does not need to be HTML first. Publish the markdown; it is rendered to a self-contained document when it is stored, not when it is read:
curl -X POST https://read.botook.ai/sites \
-H "Authorization: Bearer otr_live_YOUR_KEY" \
-H "content-type: text/markdown" \
--data-binary @NOTES.mdGate it before you send it
Access is a publish-time argument, not a second workflow:
set_password({ site_id: "…", password: "hunter2" })
set_email_gate({ site_id: "…", allowed_email_domain: "client.com" })
set_expiry({ site_id: "…", expires_in_hours: 48 })Or in one call at publish time — password, allowed_email_domain,
burn_after_read and expires_in_hours are all fields on the publish.
Read the reaction without leaving the terminal
Turn the on-page toolbar on and viewers can leave a comment anchored to the paragraph they are objecting to. The session reads them back:
list_feedback({ site_id: "…", unresolved_only: true })
resolve_feedback({ feedback_id: "…" })So a review round is: publish, send the link, keep working, and pick the feedback up as structured input on the next turn — how that loop works.
A slash command that ships the current file
Drop this in .claude/commands/publish.md and the whole thing is one keystroke:
---
description: Publish the current build output to a private link
---
Publish $ARGUMENTS (default: dist/index.html) with publish_html,
expires_in_hours 168, agentation true. Print only the URL.What this is not
It is not Vercel and it is not Netlify. There is no framework detection, no serverless function, no preview environment per branch — it hosts a document. If the project needs a build pipeline, deploy the project normally and use this for the artifacts around it: the status page, the report, the one-off tool, the thing you would otherwise paste into Notion and watch render badly.
Sending the link on
The link works in any browser with no login, so it drops cleanly into an email or a Slack thread. If it is going to a client, put your own hostname in front of it — custom domains take a CNAME and a TXT record.
One primitive, several jobs
There is exactly one publish primitive here — bytes in, unlisted URL out. Everything else in
the session is that primitive plus an argument: a password makes it a client deliverable, an expiry
makes it a temporary handoff, burn_after_read makes it a one-time credential drop,
and update_site makes it a build address. Learning one call is enough to get all of
them, which is the reason the tool surface is twelve verbs and not fifty.
What the person on the other end sees
A page, in a browser, with no login and no download. If agentation is on they also
get a small toolbar for leaving a note anchored to a paragraph; if reactions is on they
get the emoji row. When the link is pasted into a chat app, the unfurl uses a real screenshot of the
page — unless the page is gated, in which case it is deliberately a placeholder, because a preview
image that leaks a password-protected page would defeat the password.
What comes back to the session
After the link has been out for a day, the same key that published it can read what happened:
curl -s https://read.botook.ai/sites/SITE_ID/engagement \
-H "Authorization: Bearer otr_live_YOUR_KEY" | jq .Opens, unique viewers, median dwell, scroll depth in ten buckets, and whether anyone reached the end. Aggregate counters only — there is no session replay and no per-visitor trail.
Cleaning up after a session
A working session produces drafts nobody should still be able to open next month. List what the key owns, then delete what is finished:
curl -s https://read.botook.ai/sites -H "Authorization: Bearer otr_live_YOUR_KEY" | jq '.sites[] | {id,title,url}'
curl -X DELETE https://read.botook.ai/sites/SITE_ID -H "Authorization: Bearer otr_live_YOUR_KEY"Deletion removes the row and the stored object together. Anything you forget expires on its own after seven days, which is the point of having a default expiry at all.
Questions
Does the model see my key?
It sees a configured server, the same way it sees any other tool. The key sits in the client configuration; rotate or revoke it in the dashboard at any time.
Can I publish without a key at all?
Yes — the anonymous route. Short-lived, rate-limited by
address, claimable afterwards: curl -X POST https://read.botook.ai/v1/publish -F file=@index.html.
Does the page get indexed?
No. Every viewer response carries
noai, noimageai, noindex and no referrer.
What happens when it expires?
The row and the stored object are both deleted by a scheduled purge. Expiry here is deletion, not hiding.