I built a writing editor for my personal site this weekend
Writing Editor
Draft, review and publish your own personal writing with the help of AI reviews.
I’ve been rebuilding my personal site for a couple of weeks. This post is about one specific feature: a writing editor that lets me draft a post, get it reviewed by Claude against my actual code and docs, and publish directly to sajivfrancis.com — all without leaving the browser. Drafts live in Cloudflare KV — DRAFTS_KV, owner-only behind CHAT_TOKEN. Once published, only the final version goes to GitHub; the site builds from there.
Here’s the loop. One Cloudflare Worker — the chat-worker — handles all read/write to DRAFTS_KV, owner-only access enforced by CHAT_TOKEN.
flowchart TD
A[/admin/write] -->|Save| B[DRAFTS_KV<br>owner-only via CHAT_TOKEN]
B -->|Publish| C[GitHub: src/content/blog/<br>YYYY-MM-DD-slug.mdx]
C -->|git push| D[GitHub Pages rebuild]
D --> E[sajivfrancis.com/blog/slug]
mindmap
root((Personal Site Writing Editor))
Draft Management
Cloudflare KV Store
DRAFTS_KV binding
Owner-only access
CHAT_TOKEN enforcement
Browser Editor
/admin/write UI
Save draft
Review trigger
Publish action
chat-worker
Handles all KV read/write
Single Cloudflare Worker
Routes POST /draft
Routes POST /review
Routes POST /publish
Review Pass
Fetch GitHub repo source
Ground prose against code
Flag technical inaccuracies
Manual fix before publish
Corpus Grounding
pgvector store
topK 6 chunks
Topic filters docs writing meta
Query from title plus first 800 chars
docs.sajivfrancis.com
Cross-check against prior writing
Claude AI Review
Prose quality suggestions
Technical claim validation
Called by chat-worker
Only as good as its grounding
Publish Pipeline
Push MDX to GitHub
src/content/blog/YYYY-MM-DD-slug.mdx
GitHub Pages rebuild
Live at sajivfrancis.com/blog/slug
Access Control
CHAT_TOKEN on all endpoints
Drafts stay private
Only final version goes to GitHub
The review pass fetches the actual GitHub source for any repo I tag, then asks Claude to ground my prose against that code. If I write “I used Python” but ‘wrangler.toml’ says TypeScript, the suggestion flags it — I fix it manually before publishing.
This is a different way of writing. Review prose and technical claims in the same pass — fix what’s flagged, ground the build details against the actual repo, publish. The corpus grounding goes further: retrieval pulls from docs.sajivfrancis.com and the pgvector store, so I can cross-check a new post against everything I’ve already written and documented.
| Component | Role | Access |
|---|---|---|
/admin/write | Browser editor UI | Owner-only (CHAT_TOKEN) |
chat-worker | Draft read/write to KV | Owner-only (CHAT_TOKEN) |
DRAFTS_KV | Cloudflare KV draft store | Private binding |
| GitHub repo | Published MDX + images | Public on push |
| pgvector store | Corpus chunk retrieval (topK: 6) | Owner-only (CHAT_TOKEN) |
| Claude | Prose + technical review | Called by chat-worker |
sequenceDiagram
actor Owner
participant Editor as /admin/write
participant Worker as chat-worker
participant KV as DRAFTS_KV
participant GH as GitHub
participant Claude
participant PG as pgvector store
Owner->>Editor: Save draft
Editor->>Worker: POST /draft (CHAT_TOKEN)
Worker->>KV: Write draft
Owner->>Editor: Review
Editor->>Worker: POST /review (CHAT_TOKEN)
Worker->>GH: Fetch repo source
Worker->>PG: Retrieve corpus chunks (topK=6)
Worker->>Claude: Prose + grounding payload
Claude-->>Worker: Suggestions
Worker-->>Editor: Display suggestions
Owner->>Editor: Publish
Editor->>Worker: POST /publish (CHAT_TOKEN)
Worker->>GH: Push MDX to src/content/blog/
The retrieval query is constructed from the draft’s title and the first 800 characters of the body — enough signal to surface relevant chunks without overfitting to the opening paragraph. The actual call looks like this:
// pgvector corpus grounding — use draft title + body lead as the
// retrieval query. Owner mode so all visibility scopes are in play.
let corpusChunks = [];
const retrievalQuery = `${draft.frontmatter.title}\n\n${draft.body.slice(0, 800)}`;
try {
const ctx = await getContext(
retrievalQuery,
['docs', 'writing', 'meta'], // ← topic filters
undefined, // owner → no visibility filter
'owner',
clientId,
env,
{ topK: 6 } // ← matches topK in component table
);
corpusChunks = ctx.chunks ?? [];
} catch (e) {
// Non-fatal — continue with GitHub grounding only
}
The AI layer is only as useful as its grounding — GitHub source, personal docs, the vector store. Strip that away and it is just a grammar checker.