tdoc — Prompt-native HTML documents
Open-source, collaborative. Docs are HTML build artifacts, not files the user maintains.
Source of truth (see AGENTS.md): Remote storage is source of truth. Local HTML is disposable. Local skill is authoring/scaffold. Authoring interface is a prompt.
Every edit creates a new version. Comments anchor to highlighted text or to
artifacts (images, SVG, canvas, video) and are used to regenerate the next
version. Each user publishes to their own Cloudflare Worker for free always-on
sharing, with a one-time sign-in (email, Google, or GitHub) gating comments.
Storage layout
~/tdocs/
<slug>/
meta.json # { title, created, versions: [...] }
v1/index.html
v1/widgets/<name>.html # optional; sandboxed JS island, served at /widget/<name>
v2/index.html
comments.json # [{ id, version, anchor, text, status }]
Server runs at http://localhost:7878 (override with TDOC_PORT) and serves:
/— index of all docs/d/<slug>/v/<n>— a specific version (reader shell + the author document in an isolated frame)/d/<slug>/v/<n>/widget/<name>— sandboxed interactive island (no reader chrome)/api/commentsGET/POST — comment persistence/api/ping— health check; responds{"ok":true,"service":"tdoc"}. Theservicefield is the identity marker — a foreign service answering 200 on the port must NOT pass as tdoc.
Setup check
TDOC_DIR="${TDOC_DIR:-$HOME/tdocs}"
# Resolve the checkout for the agent that is running this skill. Multiple
# agents can be installed on one machine, so a fixed cross-host order can
# update Claude's checkout while Codex is using a different one (or vice
# versa). An explicit override remains authoritative.
tdoc_resolve_skill_dir() {
if [ -n "${TDOC_SKILL_DIR:-}" ]; then
printf '%s\n' "$TDOC_SKILL_DIR"
return
fi
if [ -n "${CLAUDE_CODE:-}${CLAUDE_SESSION_ID:-}${CLAUDECODE:-}${CLAUDE_CODE_ENTRYPOINT:-}${CLAUDE_CODE_SSE_PORT:-}" ]; then
for d in "$HOME/.claude/skills/tdoc" "$HOME/.agents/skills/tdoc" "$HOME/.codex/skills/tdoc"; do
[ -f "$d/SKILL.md" ] && { printf '%s\n' "$d"; return; }
done
printf '%s\n' "$HOME/.claude/skills/tdoc"
elif [ -n "${CODEX_SESSION_ID:-}${CODEX_CLI:-}${OPENAI_CODEX:-}${CODEX_HOME:-}${CODEX_SHELL:-}" ]; then
for d in "$HOME/.codex/skills/tdoc" "$HOME/.agents/skills/tdoc" "$HOME/.claude/skills/tdoc"; do
[ -f "$d/SKILL.md" ] && { printf '%s\n' "$d"; return; }
done
printf '%s\n' "$HOME/.codex/skills/tdoc"
else
for d in "$HOME/.agents/skills/tdoc" "$HOME/.claude/skills/tdoc" "$HOME/.codex/skills/tdoc"; do
[ -f "$d/SKILL.md" ] && { printf '%s\n' "$d"; return; }
done
printf '%s\n' "$HOME/.agents/skills/tdoc"
fi
}
SKILL_DIR="$(tdoc_resolve_skill_dir)"
# Always invoke the CLIs as `bash "$SKILL_DIR/bin/..."` — some skill mounts
# (Codex, hardened containers) are noexec, where the x bit is set but direct
# execution fails with Permission denied.
mkdir -p "$TDOC_DIR"
# Check server is running. Identity-check the body — 200 alone is not proof
# the answerer is tdoc; another local service can squat the port.
TDOC_PORT="${TDOC_PORT:-7878}"
PING_BODY=$(curl -sf --max-time 2 "http://localhost:${TDOC_PORT}/api/ping" 2>/dev/null || true)
if printf '%s' "$PING_BODY" | grep -q '"service" *: *"tdoc"'; then
echo "SERVER_OK"
elif [ -n "$PING_BODY" ]; then
echo "PORT_FOREIGN" # something else answers on the port — do NOT use it
else
echo "SERVER_DOWN"
fi
If PORT_FOREIGN: another service holds port ${TDOC_PORT}. If pgrep -f "$SKILL_DIR/server/server.js" finds a process, it's an outdated tdoc server —
restart it. Otherwise tell the user which process holds the port (lsof -i :${TDOC_PORT}) and either free it or set TDOC_PORT to a free port.
If server is down, start it:
nohup node "$SKILL_DIR/server/server.js" > "$TDOC_DIR/.server.log" 2>&1 &
sleep 1
Authoring contract — read before writing any doc
Three files are required reading before you write doc HTML, on every
/tdoc new and every regeneration in /tdoc edit:
| File | Governs | Selectable? |
|---|---|---|
$SKILL_DIR/authoring/voice.md |
how the prose reads | No. A floor — no switch, no doc exempt. |
$SKILL_DIR/authoring/visuals.md |
how much of the doc is a picture | No. A floor — be visual-first, many visuals, varied types. |
$SKILL_DIR/authoring/structure/components.md |
what the parts are | No. The parts are the same in every style. |
$SKILL_DIR/authoring/style/<picked>.md |
what those parts look like | Yes — you pick the entry that fits the content. |
$SKILL_DIR is the installed skill directory resolved in "Setup check"
above (~/.claude/skills/tdoc, ~/.codex/skills/tdoc, or the shared
~/.agents/skills/tdoc) —
not the current working directory, which is the user's project.
voice.md carries tdoc's adaptation of the vendored no-ai-slop rule set
($SKILL_DIR/authoring/vendor/no-ai-slop.md) — which prose the rules govern, which
spans they must never rewrite (code, identifiers, quotes, data), and whose
voice is being preserved when the agent is the one writing.
style/default.md is the stark sans style: pure white, pure black, one clean
sans everywhere (open Inter, standing in for the proprietary OpenAI Sans), an
tight-tracked headline, near-zero color, and a full technical-diagram
vocabulary (thin frames, mono pill labels, numbered containers, solid/dashed
arrows, one accent per figure, dot/hatch textured fills). The OpenAI-index
aesthetic, done with open fonts — no brand assets, a look not an identity.
Choose the style that fits the document you are about to write. It is a
judgment call, not a setting the user has to know exists: read what the content
is, then pick. A user who names one has overridden you, and that stands — but
saying nothing is not a vote for the default, it is leaving the choice to you.
default— specs, explainers, anything carried by diagrams. The stark register keeps the page quiet so the figures do the talking.technical— dense engineering writeups, benchmarks, anything where the identifiers and the numbers are the content. Opens dark-first.paper— a long read meant to be read end to end: a vision doc, a post-mortem with a story in it, an essay.editorial— the same length, but argumentative: a position piece where terms need marking as they are introduced.
When two fit, take the calmer one. The entries in full:
$SKILL_DIR/authoring/style/technical.md— a cold engineering-blog register: mono for identifiers and metrics, neutral greys for structure, a single sparing red-orange accent. For dense technical writeups.$SKILL_DIR/authoring/style/editorial.md— a long-read essay register: warm paper ground, a serif reading voice, electric-blue accent, and colored underlines that mark terms inline. The one style that overrides typography, and only the ground and body font.$SKILL_DIR/authoring/style/paper.md— a warm serif long-read: off-white paper ground, an open serif display (Fraunces) over a humanist sans body, one clay accent. The Anthropic-blog aesthetic, done with open fonts (not the proprietary brand fonts, no logo/byline — a look, not an identity).
$SKILL_DIR/authoring/structure/components.md is the component library: what
a stat tile, a comparison matrix, a container frame or a label chip is,
with no colour on it. Each style/ entry gives the same parts its own
treatment, so switching style changes how a component reads and never what
it is.
The list is open. A doc that needs a component nobody wrote down should
have one. Build it from the tokens every style declares — ink, rule,
muted, surface, accent-fill, accent-stroke, accent-text,
label-type — and it is dressed correctly by every style, including any
added later. The rest of the contract is in that file.
Which sections a doc has is decided by the prompt and the material, per doc.
visuals.md is the visual-first floor: draw generously, and pick the visual
type that fits the data (bar, line/scatter, quadrant, matrix, timeline,
stacked bar, flow). Most docs carry several different types. The style colors
them; this file decides there should be many.
Commands
/tdoc new <prompt> — create a new doc
Where it goes. A doc is published to hosted tdoc.dev and the user is
handed a shareable link. That is the default and it is not something to ask
about. Two things change it, and only if the user says so in their own words:
| The user said | Destination | What they get back |
|---|---|---|
| nothing about hosting | hosted tdoc.dev | https://tdoc.dev/d/<slug>/v/1 — link-readable, not listed anywhere |
| "publish to my own Cloudflare / Vercel", "self-host it" | their own worker | <worker>.workers.dev / tdoc-<scope>.vercel.app — still a public link, not localhost |
| "keep it local", "don't upload it anywhere", "just show me locally" | local only | http://localhost:7878/... |
The localhost rule: never hand over a localhost URL unless the user asked
to keep the doc local. Not as a fallback, not when a sign-in did not finish,
not as "here it is locally in the meantime". Asking to self-host on Cloudflare
or Vercel is NOT asking for localhost — that path still ends at a public URL.
If publishing cannot complete, say so and leave the doc in $TDOC_DIR/<slug>/;
do not substitute a local URL for the link the user was promised.
This rule is about what you hand over, not about the local server, which is
untouched. /tdoc serve still works for everyone, and previewing locally while
iterating is fine whenever the user asks for it — it is simply not what a
finished doc is delivered as.
Step 0 — start the sign-in before you start writing. Hosted publishing needs a one-time sign-in. Generating a doc takes 30–60 s and the pairing flow is a poll loop, so run them at the same time rather than interrupting the user at the end:
# no-op and instant when already signed in
bash "$SKILL_DIR/bin/tdoc-publish" --signin-only
Launch this in the background (Bash run_in_background: true) and go
straight on to writing the doc. Against a current hosted worker this is the
tdoc pairing flow: it opens tdoc.dev/activate in the user's browser with
the code prefilled — they sign in there however they like and click Approve.
Where auto-open cannot fire, relay the URL and code to the human and wait;
never open the URL in your own browser (your session is not theirs). Against
an older worker it falls back to the GitHub device flow, where the code is
typed on github.com. Tell the user in one line what opened and that the code
is in the terminal; then keep working. Skip Step 0 entirely for the local-only and self-host destinations.
Pick a slug from the prompt (kebab-case, ≤4 words).
Read
$SKILL_DIR/authoring/voice.md,$SKILL_DIR/authoring/visuals.md,$SKILL_DIR/authoring/structure/components.md, and the$SKILL_DIR/authoring/style/entry you picked. Voice constrains the prose as you generate it, not as a later cleanup pass. The style tells you which components to reach for and its palette — apply it unless the user named another entry in$SKILL_DIR/authoring/style/. The named style file is the complete visual contract: use its CSS, but do not invent a second page-wide aesthetic on top of it.Write the host document to a temp file (not into
~/tdocs— step 4 puts it there):- All host CSS inline in
<style>. Never put JavaScript in the host. Host<script>,on*=handlers, andjavascript:URLs are inert under CSP and therefore create controls or empty panels that cannot work. If the idea needs computation, writev1/widgets/<name>.htmland iframe it. - No external CDNs in the host unless requested. No build step.
- Pick the style that fits the content when the user names none. A full-page
custom design is allowed only when the user explicitly requests one;
programmatic callers must make that exception visible with
--custom-template. - Interactive: if the prompt implies a model or diagram, build it with the CSS-only techniques in "Interactivity: CSS only" —
:checkedtoggles, CSS keyframes,<style>inside the<svg>. If the idea genuinely needs computation, emit a sandboxed widget island (see that section); do NOT put<script>in the host document.
- All host CSS inline in
Hand the HTML to
bin/tdoc-write. Do not write into~/tdocsyourself.bash "$SKILL_DIR/bin/tdoc-write" \ --slug <slug> --title "<title>" --style <selected-style> \ --prompt "<the user's request, one line>" \ --html-file /tmp/<slug>.htmlOne call does everything a version needs: validates the host, bakes the reading template so the document is self-contained, writes
v1/index.html, writesmeta.json, and initializescomments.json. It prints the local URL on the last line.Doing these by hand is what let documents ship without a reading template — validation and baking are properties of writing a version, not of any one command, so they live in one place that every path goes through. Add
--widgets-dir <dir>for sandboxed islands, and--custom-templateonly when the user explicitly asked for a whole-page custom design.If it exits non-zero, fix the host and run it again; nothing has been written. Do not open, publish, or report the document as complete.
Publish and hand over the link.
Hosted (the default). Confirm the background sign-in from Step 0 finished, then publish:
bash "$SKILL_DIR/bin/tdoc-publish" <slug> # keep earlier drafts to yourself: # bash "$SKILL_DIR/bin/tdoc-publish" --history owner <slug>Report the
https://tdoc.dev/d/<slug>/v/1URL on its own line, and say what it is — the user may never have seen a tdoc page before. Describe the access it actually has, which for a plain publish is the legacy policy:Your doc is live. Anyone with this link can read it — and can page back through earlier versions — but it is not listed anywhere, so only people you send it to will find it.
Do not call it "unlisted". A publish with no explicit flags stores no access block and takes the legacy policy (
visibility: public,history_visibility: public); saying unlisted would understate what a recipient can see. If the user wants earlier versions kept private, that is--history owner.If the sign-in has not completed yet, do not fall back to localhost and do not go quiet. Say the doc is written and waiting, and that approving the approval page finishes it — or that they can say "publish it" later and you'll get them a fresh code. The doc stays in
$TDOC_DIR/<slug>/.Self-host.
bash "$SKILL_DIR/bin/tdoc-publish" --platform cloudflare <slug>(orvercel). Report the worker URL, with the same note about access.Local preview stays available to self-hosting users —
/tdoc serveandhttp://localhost:7878are unchanged, and iterating locally before pushing to your own worker is a perfectly good loop. That is an authoring step the user can ask for at any time; it does not change what gets handed over at the end, which is still the worker URL. Nothing about the local server was removed.Local only — because the user asked. Start the server if needed and open the local URL:
open "http://localhost:7878/d/<slug>/v/1"This is the only branch that reports a
localhostURL.
bin/tdoc-new — programmatic entry for agents in other skills
This is the contract OTHER skills (/document-release, /retro,
/investigate, /cso, /qa-only, /office-hours, /plan-*, etc.)
use when an agent inside them is about to emit a doc-shaped artifact.
The human-facing /tdoc new flow is a chat-driven prompt → HTML
generation. bin/tdoc-new is the other direction: the calling agent
already has the finished HTML and just wants tdoc to scaffold storage,
serve it locally, and (optionally) publish.
When to use it: any time inside another skill you would otherwise
have written cat > some-report.md <<EOF ... with more than a couple
paragraphs of structured content. Generate the doc as HTML (use the
template + styling rules from the /tdoc new section above), then
hand it off:
HTML_FILE=$(mktemp -t tdoc-handoff.XXXXXX.html)
cat > "$HTML_FILE" <<'HTML'
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>...</title></head>
<body><div class="wrap">
<h1>...</h1>
<!-- sections; tag author-composed wrappers data-tdoc-artifact
wherever you want a comment surface -->
</div></body>
</html>
HTML
TDOC_NEW_CALLER=document-release \
~/.claude/skills/tdoc/bin/tdoc-new \
--slug "release-notes-$(date +%Y%m%d)" \
--title "Release notes — $(date +%Y-%m-%d)" \
--html-file "$HTML_FILE" \
--publish
Args:
--slug <kebab-case>(required) — slug for~/tdocs/<slug>/.--title "<title>"(required) — recorded inmeta.json.--html-file <path>OR--html-stdin(required) — full HTML for v1.--widgets-dir <path>— optional directory of sandboxed widget HTML files. Each<name>.htmlis stored asv1/widgets/<name>.html; JavaScript belongs there, never in the host HTML.--prompt "<one-line>"— prompt-of-record inmeta.json(defaults toImported via tdoc-new by <caller>).--publish— also runtdoc-publishso a shareable URL is returned.--open— open the resulting URL in the default browser.--quiet— suppress informational output (the URL is still printed on the last line so callers can capture it).--style default|technical|editorial|paper— selected house-style contract. Omit it to usedefault.--custom-template— explicit opt-out from the default template for a user-requested presentation, landing page, or full-bleed simulation. Normal docs must not pass it.--force— overwrite an existing slug. Without this, an existing slug is a hard error (no silent clobber).
Output contract: the local URL is always the last line on stdout.
If --publish succeeded, the published URL appears on a second line.
This is what callers should tail -n 1 (or tail -n 2) to capture.
Guards built in: refuses to clobber existing slugs without --force;
validates the host before replacing an existing doc; copies explicitly
supplied widget files; restarts the local server if needed. Host validation
rejects <script>, on*= handlers, javascript: URLs, and <canvas> even in
custom-template mode, because all of them are inert under the host CSP and can
silently create empty UI. It also enforces the selected house-style boundary.
Whole-page custom styling requires the deliberate --custom-template flag;
that flag never permits host JavaScript.
Set TDOC_NEW_CALLER (or rely on CLAUDE_SKILL_NAME) so meta.json
records which skill scaffolded the doc — useful for later auditing or
for /tdoc list to show provenance.
/tdoc edit <slug> [<extra prompt>] — new version from comments
You MUST report back on every open comment — applied, partial, or unclear. This is a hard requirement, not a suggestion. The user can't tell which comments you handled unless you reply on each one. Skipping comments silently is the #1 source of regression complaints.
Pull the comments first, then read them.
~/tdocs/<slug>/comments.jsonis a cache of a file other people are writing: everything said since your last round — including a comment someone deleted — is only in the published doc. Skip for a doc that was never published.bash "$SKILL_DIR/bin/tdoc-pull" <slug>Then read
~/tdocs/<slug>/comments.jsonand filter tostatus: "open".Get the current document — remote is the source of truth, local is a cache. The local
v<n>/index.htmlcan be stale: a browser edit or a publish from another machine creates versions your checkout never saw, and an edit based on a stale copy silently discards them. One conditional request settles it (published.jsonholds the base URL; skip this entirely for a doc that was never published):REMOTE_SHA="$(curl -sfI "$BASE/d/<slug>/v/<n>/raw" | tr -d '\r' | sed -n 's/^etag: "\(.*\)"$/\1/Ip')" LOCAL_SHA="$(node -e 'const m=require(process.argv[1]);const e=(m.versions||[]).find(v=>v.n===Number(process.argv[2]));console.log(e&&e.sha||"")' "$TDOC_DIR/<slug>/meta.json" <n>)"- Match → your local copy produced what remote holds; use it as the base.
- Differ (or no local sha) → pull the truth:
curl -sf "$BASE/d/<slug>/v/<n>/raw" -o "$TDOC_DIR/<slug>/v<n>/index.html"and base the edit on that. - Unreachable → use the local copy, and say so in your reply: the edit is based on a possibly-stale cache.
Then re-read
$SKILL_DIR/authoring/voice.md,$SKILL_DIR/authoring/visuals.mdand$SKILL_DIR/authoring/structure/components.md. A regeneration writes new prose, so the contract applies here exactly as it does on/tdoc new. Prose you carry over unchanged from the previous version stays as it is — do not re-edit untouched sections for voice, and keep whichever style the existing version already uses rather than restyling a doc the reader has been reading.For EACH open comment, decide one of three outcomes BEFORE writing:
- applied — the comment is clear and you can act on it.
- partial — you applied part of it but couldn't fully address it (e.g. the user asked to "add a chart and explain compound interest"; you added the chart but the explanation is shallow).
- question — you can't act without clarification (the comment is ambiguous, contradicts another comment, or refers to content that doesn't exist in the current doc).
Regenerate the full HTML to a temp file, incorporating every
appliedandpartialcomment. A comment's anchor has:anchor.text— the exact text the user highlighted (may span across paragraphs and inline elements)anchor.context_before/anchor.context_after— surrounding text (~60 chars each side) for disambiguation when the same text appears multiple times
Hand it to
bin/tdoc-write --version next. Do not writev<n+1>/yourself.bash "$SKILL_DIR/bin/tdoc-write" \ --slug <slug> --title "<existing title>" --style <the doc's style> \ --prompt "<what this revision changes, one line>" \ --html-file /tmp/<slug>-next.html --version nextSame gateway as
/tdoc new, so a new version gets the same treatment a first version does: validated, baked,meta.jsonappended, andcomments.jsonleft alone — the thread you are answering survives. Earlier versions are untouched.This is not a convenience. Writing
v<n+1>/index.htmlby hand skipped the bake, so a document that predates creation-time baking could be edited any number of times and still ship without a reading template — it had no path to recover on its own. The gateway is that path.For each comment, post an agent reply so the user sees the outcome in the doc UI. This is mandatory.
Use
bin/tdoc-agent-reply. It auto-detects the host runtime (Claude Code, Codex, Grok, Cursor, Gemini) from the process environment and stampsagent_loginso the comment shows that product's logo. Do not invent a login or passtdoc-agent. Only pass--loginif you must override detection. The published Worker cannot see your env, so do not raw-curl/api/agent/replyyourself — the helper stamps identity before the request leaves the machine.bash "$SKILL_DIR/bin/tdoc-agent-reply" \ --slug "<slug>" \ --parent "<comment_id>" \ --text "<one or two sentences>" \ --status applied \ --applied-in <n+1>It posts to the published Worker when
~/.tdoc/published.jsonexists, otherwise tohttp://localhost:${TDOC_PORT:-7878}. Users can also reply to any reply (HN/Reddit-style nesting);parentis the comment or reply you are answering.A skip is a normal outcome, not an error. The published Worker answers a comment once per human turn: if your answer is already the last word on that thread it prints
not posted: this comment already has your answerand exits 0. That is the server protecting the reader from hearing the same thing twice — most often because they deleted your last answer, which removes it from the comments.json you just read but not from the log the server keeps. Do not retry it, and do not reach for--force: pass that only when a person has asked you to say it again.The reply text should be specific:
- applied: "Rewrote the second paragraph in English. The section heading is now 'What an Agent Needs'."
- partial: "Added the chart but the compound-interest explainer is still basic — want me to flesh it out?"
- question: "Two of your comments asked for different tones — formal in the intro and casual in section II. Which should I prioritize?"
Update
comments.json: setstatus: "applied"(or leave"open"for partial/question) andapplied_in: n+1. The agent-reply endpoint already flips the status server-side AND drops a status emoji on the parent comment (✅ applied, 🟡 partial, ❓ question), clearing any previous agent emoji first. You don't need to send a separate reaction request — the reply endpoint does it. Users see the verdict at a glance from the comment cards without expanding replies.If a comment is later re-anchored by the user (anchor moved to new text), the server automatically clears the agent's emoji and resets
status: "open". Re-running/tdoc editwill pick it up again.Publish the new version and hand back its link, the same way
/tdoc newdoes. A doc that was published stays published; reporthttps://tdoc.dev/d/<slug>/v/<n+1>so the reviewer can see the version their comment produced. The link a user already shared keeps working — a new version never breaks it.bash "$SKILL_DIR/bin/tdoc-publish" <slug>Only report a
localhostURL if this doc is local-only because the user asked for that (see the localhost rule in/tdoc new).
If there are zero open comments AND no extra prompt, ask the user what to change before doing anything.
/tdoc fork <slug> [<new-slug>] — copy a doc
cp -R "$TDOC_DIR/<slug>" "$TDOC_DIR/<new-slug>"
Reset comments.json to []. Update meta.json title to include (fork).
/tdoc list — show all docs
Read each meta.json and print: slug, title, latest version, # open comments.
/tdoc serve — (re)start the server
pkill -f "$SKILL_DIR/server/server.js" 2>/dev/null
nohup node "$SKILL_DIR/server/server.js" > "$TDOC_DIR/.server.log" 2>&1 &
echo "tdoc server: http://localhost:7878"
/tdoc stop — stop the server
pkill -f "$SKILL_DIR/server/server.js"
/tdoc publish <slug> — publish to hosted tdoc (default), or self-host
Publishes the latest version of <slug> to a public URL.
Architecture — publish auth, multi-tenant scoping, account/BYOK
switching, and the client-version gap — is written up as a tdoc:
docs/publish-auth-architecture.html (live: tdoc.dev/d/tdoc-auth-arch). Read
it before changing bin/tdoc-publish, bin/tdoc-update-nag, or the worker
auth/hosted-token routes.
Default target is hosted (https://tdoc.dev). First run signs in with
the tdoc pairing flow: the CLI shows a short code, the human approves it at
tdoc.dev/activate in their own browser (signed in with whatever that page
offers), and the poll returns an account-scoped upload token stored in
~/.tdoc/published.json. Workers that predate pairing fall back to the
GitHub Device Flow automatically. That token can
only mutate docs it owns. The sign-in is resumable: if the process dies
while waiting (agent harness timeout, killed sandbox), just run the same
command again — it picks up the pending device code and keeps polling, so an
approval the human already granted still lands. Never mint a fresh sign-in by
hand after an interruption; the re-run does the right thing.
/me on tdoc.dev lists that account's docs. If
hosted signup is not open on the target, the CLI fails with a clear prompt to
self-host instead — do not tell the user to flip a Worker env flag.
Self-host — Cloudflare: tdoc-publish --platform cloudflare <slug>.
First run (or an explicit switch onto cloudflare) prompts wrangler login,
creates an R2 bucket (tdoc-docs) and KV namespace (META) in your
Cloudflare account, generates an upload token, and deploys your own Worker.
The choice is persisted in ~/.tdoc/published.json as the default.
Self-host — Vercel: tdoc-publish --platform vercel <slug>. First run
(or an explicit switch onto vercel) needs the vercel CLI (npm i -g vercel),
links a Vercel project named tdoc, then asks you (via an agent prompt) to
connect a Blob store and an Upstash Redis store in the Vercel
dashboard's Storage tab — both free tier, ~2 clicks each — and deploys.
Caveats: no per-doc write serialization (Cloudflare uses a Durable Object for
that) and a ~4.5 MB upload cap per doc (Vercel request limit).
Subsequent runs upload the latest version of <slug> using the saved default.
Pass a different --platform any time to switch: full re-setup rewrites
published.json (previous file kept as published.json.bak.switch). A custom
domain and *.workers.dev on the same Worker are two hostnames, not two
platforms. Self-host targets
compare a content hash of the bundled Worker (shell + probe + reader CSS) against the last deployed
hash in ~/.tdoc/published.json and redeploy automatically when runtime code
changed. Set TDOC_SKIP_WORKER_DEPLOY=1 to skip the redeploy (useful for batch
uploads). Published pages expose runtime provenance at /api/runtime and in
window.__TDOC__.runtime.
Existing GitHub users migrate by doing nothing. Their saved upload token keeps working (nothing in the CLI re-authenticates until the token is lost), and in the browser they pick GitHub inside the sign-in page — the worker recognises the connected GitHub identity and lands them on their existing account, docs intact — and the session keeps their verified handle, so old comments stay editable and handle-shaped invites keep matching. (The bridge needs CLERK_SECRET_KEY on the worker; without it a legacy user should pick GitHub via the legacy device flow instead.) There is nothing for the local skill to detect or convert; the pending-signin/pairing machinery is the same file either way.
Local preview (tdoc serve) does not need any sign-in. Published docs —
hosted (tdoc.dev) and BYOK remote (your Cloudflare/Vercel worker) — gate
commenting behind a sign-in. On hosted that is the provider seat (email,
Google, or GitHub, all in one page). On a BYOK worker with no OIDC config the
LEGACY fallback is GitHub Device Flow via the org-owned OAuth App in
shared/github-oauth.js (scope read:user); viewers authorize that shared
app, they do not register their own, and the App's callback URL is
https://<host>/auth/github/callback (a device approve may still bounce to
/auth/done, a friendly static page). shared/github-oauth.js stays the
source of truth for that fallback only.
Hosted needs no extra CLI beyond Node 18+ and curl. Self-hosting needs jq. Cloudflare needs wrangler
(npm i -g wrangler); Vercel needs vercel (npm i -g vercel).
bash "$SKILL_DIR/bin/tdoc-publish" <slug>
Prints the published URL: https://tdoc.dev/d/<slug>/v/<N> (hosted),
https://<worker>.<subdomain>.workers.dev/d/<slug>/v/<N> (Cloudflare), or
https://tdoc-<scope>.vercel.app/d/<slug>/v/<N> (Vercel).
/tdoc pull <slug> — pull comments from the published doc
Overwrites local ~/tdocs/<slug>/comments.json with comments collected on the
published Worker. Run before /tdoc edit to regenerate using community feedback.
bash "$SKILL_DIR/bin/tdoc-pull" <slug>
/tdoc unpublish <slug> — remove from your Worker
Deletes all versions, meta, and comments for <slug> from R2/KV. Local files
are untouched.
bash "$SKILL_DIR/bin/tdoc-unpublish" <slug>
/tdoc onboard — guided first-time setup
You are walking a user through tdoc onboarding. The user might have nothing
installed, or might be partway through. You must drive the flow from
bin/tdoc-doctor --json output, not assume state.
Algorithm:
- Run
bash "$SKILL_DIR/bin/tdoc-doctor" --jsonand parse the JSON. This is non-destructive. The doctor is target-aware and reports what it assessed under.target. The default ishosted(tdoc.dev), which needs only Node 18+ and curl — no Cloudflare account, no wrangler, nothing to click in a dashboard. Only pass--platform cloudflare/--platform vercelwhen the user has asked to self-host. - If
.ready_to_publish == trueAND.published.ok == true→ tell the user they are fully set up, and offer to run/tdoc new <prompt>or to test publishing with a sample doc. - If
.ready_to_publish == trueAND.published.ok == false→ they have all deps but haven't published yet. Offer to create a quick sample doc with/tdoc newand then/tdoc publishit. - Otherwise, walk through
.missing_stepsin order. On the hosted default this list is usually empty. For each step:- kind == "install": run the
cmdfor them via Bash (e.g.brew install jq). After install, re-runtdoc-doctor --jsonto confirm. - kind == "login": explain that this opens a browser, then run the
cmd.wrangler loginis interactive — print clear instructions and wait. - kind == "click": you cannot click for the user. Print the URL clearly
and tell them what to do ("Open this and click 'Enable R2'"). Then wait
for the user to say "done", then re-run
tdoc-doctor --jsonto verify.loginandclicksteps are self-host only. If one appears for a user who never asked to self-host, re-read.targetbefore sending them to a dashboard.
- kind == "install": run the
- After every step, re-run
tdoc-doctor --jsonand continue from the new state. - When
.ready_to_publish == true, congratulate and offer to create + publish a sample doc.
Important behavioral rules:
- NEVER skip the doctor check before suggesting a step. State changes between steps (e.g. R2 takes a few seconds after enabling).
- NEVER walk a hosted user through Cloudflare setup. Publishing to tdoc.dev does not use wrangler, a workers.dev subdomain, or R2.
- ALWAYS show the user what you're running. Print the JSON status if helpful.
- If a "click" step doesn't take effect after the user says "done", offer to re-check after waiting 10s (Cloudflare API can be slow to reflect changes).
- Published/BYOK remotes bake in the shared org OAuth client ID from
shared/github-oauth.js— users do NOT register their own. Local preview never needs that login path.
/tdoc update — check for updates and pull the latest
Wraps bin/tdoc-update. Runs git fetch + git merge --ff-only against
origin/main of tornado-doc/tdoc.
tdoc-update --check→ report-only, prints incoming commits without changing anythingtdoc-update→ apply, with auto-stash of local edits, auto-restarts the running local server so new routes / shell code take effecttdoc-update --yes→ also redeploy the Worker so readers get the new shell
BYOK CLIs (tdoc-publish / pull / unpublish / new) and every skill
run also check origin/main and nag immediately when this checkout is
behind. tdoc-doctor reports the same as .update (not a missing_step).
bash "$SKILL_DIR/bin/tdoc-update" --check # see what's new
bash "$SKILL_DIR/bin/tdoc-update" # apply
bash "$SKILL_DIR/bin/tdoc-update" --yes # apply + redeploy worker
If the user has not yet git clone'd (the skill dir is not a git checkout),
the script prints a clean instruction to re-clone.
/tdoc doctor — health check, no changes
Prints a concise human health summary. Use this when the user reports a
problem; pass --json when an agent needs the full machine report.
bash "$SKILL_DIR/bin/tdoc-doctor"
bash "$SKILL_DIR/bin/tdoc-doctor" --json
Troubleshooting
When the user reports a problem, check these first:
/api/publish404, or "string did not match the expected pattern" in the Publish modal → the running server is stale (old process, doesn't have current routes). Restart it:pkill -f "$SKILL_DIR/server/server.js" && nohup node "$SKILL_DIR/server/server.js" > "$TDOC_DIR/.server.log" 2>&1 &./tdoc updatenow auto-restarts, but a server that was started before the update is still running stale code until restarted.- Comment popup doesn't appear when selecting text → selection is captured by
server/frame-probe.jsinside the author frame and posted to the shell overpostMessage; the composer is drawn byshell/src/document/. Check the probe's mouseup/touchend handler first, then whether thetdoc:selectionmessage reaches the shell. - Publish modal hangs forever → check
~/tdocs/.server.log. On the BYOK path it is usuallywrangler loginwaiting for browser auth, or R2 not enabled. On a first hosted publish the modal now shows the pairing code itself and waits for it, so a hang there means the sign-in was never approved — the code expires and the publish fails on its own. - Local doc URLs show the wrong content / weird JSON, or the server "is up" but docs 404 → another local service may be squatting the tdoc port (seen in the wild: a daemon from another product bound 7878). Run
curl -s http://localhost:7878/api/ping— if the body lacks"service":"tdoc", the answerer is not tdoc. Identify the squatter withlsof -i :7878, then free the port or run tdoc on another port viaTDOC_PORT=<port>(the bin scripts and server all honor it).
HTML generation rules
- The prose in the doc is governed by
$SKILL_DIR/authoring/voice.md. These rules cover markup; that file covers the words inside it. Both apply to every doc. It also fences off the spans the prose rules must never touch — code, identifiers, quoted material, and data. - Host HTML does not run author JavaScript. The author document is served on its own route,
/d/<slug>/v/<n>/frame, inside a sandboxed iframe under a nonce-based CSP (script-src 'nonce-<n>' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; sandbox allow-scripts). The nonce is stamped onto exactly one injected script —server/frame-probe.js, the anchoring/selection probe — and nothing else. Host<script>tags (inline orsrc),onclick=/onchange=attributes, andjavascript:URLs have no nonce, so the browser refuses them: no error in the page, no visible failure — just a control that never does anything. This is true on both the local server (server/server.js→frameCspHeader, the/frameroute) and published docs (worker/worker.js→frameCspHeader). The reader chrome (top bar, comments) is a separate React document that never shares the author frame's origin. Exception — sandboxed island: if the doc needs computation, writev<n>/widgets/<name>.htmland embed<iframe sandbox="allow-scripts" src="/d/<slug>/v/<n>/widget/<name>">. Inline<script>in that widget file does run. Never put author JS in the host document. See "When the prompt wants something CSS can't
…(truncated)