An internal social-media scheduling tool for the @CloudflareDev X account, built by Cloudflare DevRel. (It schedules for X - so naturally it's called Y.)
Teams across Cloudflare - PMs shipping a feature, the events team promoting a meetup, partners coordinating a launch - use Y to get posts onto the @CloudflareDev handle. It's a real review pipeline: anyone behind Cloudflare Access can write a draft, submit it for review, and propose a time; an admin approves it; and the post goes out automatically (or immediately) from the official account.
It also solves the other half of the problem - people who want a post written for them without handing over the full context. The built-in Copilot (a writing assistant agent) researches Cloudflare docs itself, drafts in the @CloudflareDev voice, and hands back copy-ready options.
- Highlights
- Tech stack
- Roles & auth
- The post lifecycle
- The composer
- Copilot - the writing assistant
- The "talk like me" skill: style guide + evals
- Cloudflare Fact Check
- How posting works
- Project structure
- Configuration
- Local development
- Scripts
- Deployment
- Review pipeline - draft → submit → admin approval → schedule/post, with a full audit log of who did what.
- Scheduling - pick a future time; a Durable Object alarm posts it automatically. Members propose times; admins approve.
- X-style composer - character ring (URLs counted as 23 chars), up to 4 images or a video (stored in R2), link previews.
- AI refine menu - grammar, improve, punchier, condense, elaborate, rephrase, tone shifts - each shown as an accept/reject word-level diff.
- Cloudflare Fact Check - checks a draft against live Cloudflare docs via the Docs MCP server and returns a verdict + sources.
- Copilot - an agentic writing assistant that researches docs and produces draft options in the @CloudflareDev voice.
- Self-improving voice - a distilled style guide learned from the posted corpus + curated reference tweets, plus an eval harness to measure how on-brand the drafts are.
- Everything on Cloudflare - Workers, D1, R2, Durable Objects, Workers AI, the Docs MCP, and Access. No external infra.
| Layer | Choice |
|---|---|
| Framework | TanStack Start (RC) on Vite |
| Runtime | Cloudflare Workers (src/server.ts is the Worker entry) |
| Database | Cloudflare D1 (SQLite) via Drizzle ORM |
| Object storage | Cloudflare R2 (STORAGE) for media |
| Scheduling | Durable Object (Scheduler) with a single alarm |
| AI agent | Durable Object (DraftAgent) on the Agents SDK |
| LLMs | Workers AI via workers-ai-provider + the Vercel AI SDK (v6) |
| Research / grounding | Cloudflare Docs MCP (https://docs.mcp.cloudflare.com/mcp) |
| Auth | Cloudflare Access (Access JWT verified with jose) |
| Posting | X/Twitter API v2 with OAuth 1.0a (single fixed handle) |
| UI | @cloudflare/kumo + Tailwind v4, lucide-react, react-tweet |
| Validation | Zod (+ drizzle-zod) |
The whole app sits behind a Cloudflare Access self-hosted application. On
every request Access injects a signed JWT (Cf-Access-Jwt-Assertion), which the
app verifies against the team's public keys and reads the user's email + name
from (src/lib/auth.ts). The plain email header is never trusted on its own.
There are two roles:
- Members - anyone who can get through Access. They can write drafts, submit them for review, propose a schedule time, and edit/delete their own posts.
- Admins - an allow-list in
src/lib/admins.ts. They can approve, schedule, post immediately, reject, and access the/adminCopilot tooling. Admins skip review entirely - their drafts post directly.
Local dev has no Access in front of it. When there's no JWT, the app falls back to
DEV_EMAILfrom.dev.vars(set it to an admin or member email to test either experience).DEV_EMAILmust never be set in production - prod is fail-closed.
Every post is a row in the posts table moving through these statuses
(src/db/schema.ts):
submit approve + schedule
┌────────┐ for review ┌─────────┐ (admin) ┌───────────┐ alarm fires ┌────────┐
│ draft │ ───────────────▶ │ pending │ ─────────────▶ │ scheduled │ ──────────────▶ │ posted │
└────────┘ └─────────┘ └───────────┘ └────────┘
▲ │ │ ▲
│ edit & resubmit │ │ approve & post now (admin) │
│ │ └──────────────────────────────────────────────────────┘
┌──────────┐ reject (admin) │
│ rejected │ ◀──────────────────┘ ┌────────┐
└──────────┘ │ failed │ (X API rejected the post)
└────────┘
| Status | Meaning |
|---|---|
draft |
Author is still editing. |
pending |
Submitted, waiting for an admin. May carry a proposed scheduledAt. |
scheduled |
Admin-approved and queued for a future time. |
posted |
Sent to X. Stores the tweetId. |
rejected |
Admin declined, with an optional note back to the author. |
failed |
The X API rejected it; the error is stored in reviewNote. |
Two typical paths:
- Member flow - write a draft → Submit for review (or propose a time,
which lands as
pendingwith ascheduledAt) → an admin approves it → it posts now or at the scheduled time. - Admin flow - write a draft → Post (immediate) or Schedule. Admins don't go through review. From the pending queue they can Approve at the proposed time, Reschedule, Post now, or Reject.
Every transition is appended to an immutable post_events audit log
(src/lib/events.ts).
The server functions that drive all of this live in src/lib/api.ts:
listPosts, createPost, updatePost, submitPost, deletePost,
schedulePost, approveAndPostNow, rejectPost.
The composer (src/components/post-editor.tsx) is a monochrome, X-style tweet
editor:
- Character ring at 280 chars, with URLs counted as 23 (
src/lib/tweet-text.ts). - Media - up to 4 images or a video, uploaded to R2 via
/api/media. - Link preview - pulls OpenGraph metadata for the first URL via
/api/link-preview. - Refine with AI - a Workers AI single-shot rewrite. Pick an action
(grammar / improve / punchier / condense / elaborate / rephrase / positive /
announcement) and the result is shown as an inline word-level diff you
accept or reject. Defined in
src/lib/ai.ts. - Fact Check - see Cloudflare Fact Check.
- Keyboard shortcut - press
n(when not typing) to start a new draft.
Copilot is the standout feature. It exists for the case where someone wants a post but doesn't give the full context - instead of a back-and-forth, Copilot researches the missing context itself (against Cloudflare's docs) and drafts in the team's voice.
It's an agent, not a chatbot. The backend is a Durable Object,
DraftAgent, built on the Cloudflare Agents SDK (src/durable/draft-agent.ts),
which connects to the Cloudflare Docs MCP server and runs Workers AI models
with tool-calling.
You talk to it from the Copilot tab in the right rail
(src/components/schedule-agenda-sidebar.tsx). It supports model switching
(Kimi K2.7, GLM 5.2, Kimi K2.6, Gemma 4 - Gemma 4 is the default) and a set of
shortcuts (press /): New product announcement, Event / meetup invite,
Twitter Roundup. Each shortcut drops a fill-in template into the composer so
you supply what you know and the agent looks up / asks for only what's missing.
┌────────────┐ POST /api/copilot/chat ┌──────────────────────┐ getAgentByName ┌──────────────────┐
│ Copilot │ ─────────────────────────▶ │ copilot.chat route │ ──────────────────▶ │ DraftAgent │
│ UI panel │ { model, messages } │ (auth + validate) │ (Durable Object) │ (Agents SDK) │
└────────────┘ └──────────────────────┘ └────────┬─────────┘
▲ │
│ 1. resolveVoiceInstructions() ◀────────────┤
│ (active style guide or fallback) │
│ 2. connect to Cloudflare Docs MCP │
│ SSE stream 3. streamText() with MCP tools │
│ tool ▸ reasoning ▸ text ▸ tweet-options 4. agent researches + writes prose │
└──────────────────────────────────────────────5. generateTweetDraftOptions() → cards ────┘
Step by step (DraftAgent.handleCopilotChat):
-
Auth + validation.
/api/copilot/chatauthenticates the Access user and validates the request (model + message history, Zod). It then forwards to theDraftAgentDurable Object viagetAgentByName(env.DraftAgent, "cloudflaredev"). The agent is a single named instance shared by the whole team. -
Voice resolution. The agent calls
resolveVoiceInstructions()- this returns the distilled @CloudflareDev style guide (if one has been generated) plus representative examples, or a hand-written fallback. Because this is read per request, regenerating the guide takes effect with no redeploy. -
Docs MCP connection. The agent ensures it's connected to the Cloudflare Docs MCP server and exposes those docs tools to the model.
-
Agentic generation. It runs
streamText()with a system prompt =voice + agent-behavior + user-context, capped at a handful of tool steps. The agent proactively looks up product/feature/model/changelog pages instead of asking the user for links, drafts immediately once it has enough public context, and streams everything back as Server-Sent Events:tool/tool-done/tool-error- each docs lookup,reasoning- the model's thinking,text- the assistant's prose reply (short: it summarizes the source it found and any caveats, rather than dumping every draft inline).
-
Structured draft cards. After the prose finishes, the agent makes a second, schema-constrained model call (
generateTweetDraftOptions) that turns the conversation + researched context into copy-ready draft cards - each with atitle,angle,body, optionallink, reviewnotes, and a character count. These stream down as a finaltweet-optionsevent and render as cards in the UI. Clicking Use draft drops the body straight into the composer.- The structured call is constrained to the same voice guide and told not to invent handles, dates, pricing, benchmarks, or URLs.
- Workers AI's tool-calling structured-output path can hang on some models,
so structured JSON is generated as plain text and tolerantly parsed +
Zod-validated (
src/lib/model-json.ts). If that still yields nothing, a deterministic fallback parser recoversOption Nblocks from the agent's own prose - so you always get usable cards.
The same DraftAgent also powers the Fact Check via a
separate /cross-reference route on the agent.
Copilot only sounds like @CloudflareDev because of a distilled style guide. Voice is treated as a small, stable thing that's learned by distillation - distinct from facts, which are grounded live via the Docs MCP.
- Corpus = real posted tweets (
postswherestatus = 'posted') ∪ curated reference tweets, deduped. The reference set seeds the voice before there are many real posts, and real posts pile on as the app is used. Nothing here ever mutates thepoststable. distillAndStoreStyleGuide()runs an instruct model over the corpus and produces a compact markdown guide: voice & tone, formatting conventions, per-shape skeletons (announcement / event / partner launch / roundup), and contrastive do/don't pairs. It stores it as the single active guide.- The agent reads the active guide at request time, so the voice improves as the corpus grows - no redeploy.
- Admins can hand-edit the active guide (
updateActiveStyleGuideContent).
Curated exemplar tweets the team wants the guide to always learn from - including ones posted before this app existed. Managed (add / edit / delete / seed) from the admin UI. There's a starter set you can seed in one click.
How do you know the guide is working? The eval harness gives you a number:
- Sample recent real posted tweets.
- Strip each one back to a neutral brief - the raw facts a user would have typed before writing it (no style, no phrasing).
- Have Copilot re-draft from that brief using the active style guide.
- An LLM judge scores the candidate against the real tweet on voice, formatting, and structure (1–5 each, plus overall) - explicitly not wording or facts, since those legitimately differ.
Scores are stored against the guide version that produced them, so you can watch the number move as you iterate. Needs at least 3 posted tweets to run.
All of this is managed from /admin (admin-only), which has tabs for
Overview, References, Style guide, and Evals. The backing API is
/api/admin/copilot (src/routes/api/admin.copilot.ts).
In the composer, Check against Cloudflare docs runs a draft through the
DraftAgent's /cross-reference route (/api/docs-check). The agent searches
the Cloudflare Docs MCP server (at most two lookups), then streams back its
reasoning and a structured verdict:
- Verdict -
Looks accurate,Needs attention, orNo factual claims. - Notes - up to 3 short bullets on anything inaccurate, outdated, or worth tightening.
- Docs - up to 3 relevant doc URLs, rendered as source chips.
The UI (post-editor.tsx) shows a live reasoning panel and parses the verdict
into a badge + notes + clickable sources.
A singleton Durable Object (addressed by idFromName("default")) owns one
alarm, set to the earliest upcoming scheduled post:
- When a post is scheduled (or one is removed), the app calls
scheduler.sync(), which re-arms the alarm to the next due post. - When the alarm fires, it posts everything that's due, marks each
posted(storing thetweetId) orfailed(storing the error), logs the event, and re-arms for the next one. - D1 is the source of truth - the DO only owns alarm timing, so it can restart without losing scheduled posts.
A minimal X/Twitter API v2 client that posts from a single fixed handle using OAuth 1.0a user context (signed with Web Crypto). Because we always post as one account, the four credentials are generated once and stored as Worker secrets - no interactive login, no token refresh. Media is pulled from R2 and uploaded to X before the tweet is created.
src/
server.ts # Worker entry - exports Scheduler + DraftAgent DOs
router.tsx # TanStack Router setup
db/
schema.ts # Drizzle tables + Zod schemas (posts, events,
# style_guides, reference_tweets, eval_runs/cases)
index.ts # Drizzle client over env.DB
durable/
scheduler.ts # Singleton alarm that auto-posts scheduled tweets
draft-agent.ts # Agents-SDK DO: Copilot chat + docs fact check
lib/
api.ts # Server functions (post CRUD + review/schedule)
auth.ts # Cloudflare Access JWT verification
admins.ts # Admin allow-list
twitter.ts # OAuth 1.0a X API v2 client
ai.ts # Refine actions (single-shot rewrites)
cloudflaredev-copilot.ts # Models, starters, fallback voice, example posts
style-guide.ts # Distill + resolve the @CloudflareDev voice
style-eval.ts # Brief → redraft → LLM-judge eval harness
reference-tweets.ts # Curated exemplar tweets for the corpus
model-json.ts # Robust structured output for Workers AI
tweet-text.ts # Tweet length / URL handling
events.ts # Audit log helper
routes/
__root.tsx # Root layout + Access gate
index.tsx # The workspace (sidebar / composer / agenda + Copilot)
admin.tsx # Admin: references, style guide, evals
api/
copilot.chat.ts # POST → DraftAgent Copilot chat (SSE)
docs-check.ts # POST → DraftAgent fact check (SSE)
admin.copilot.ts # GET/POST admin tooling (guide/refs/evals)
media.ts # R2 upload + serve
files.ts # R2 files API
link-preview.ts # OpenGraph link previews
components/ # Composer, preview, sidebars, dialogs, Copilot panel
drizzle/ # Generated SQL migrations (applied by wrangler)
wrangler.jsonc # Bindings + Worker config
AGENTS.md # Patterns for AI agents working in this repo
| Binding | Type | Purpose |
|---|---|---|
DB |
D1 | All app data |
STORAGE |
R2 | Uploaded media |
AI |
Workers AI | All LLM calls |
SCHEDULER |
Durable Object | Auto-posting alarm |
DraftAgent |
Durable Object | Copilot + fact-check agent |
| Var | Purpose |
|---|---|
ACCESS_TEAM_DOMAIN |
Your Access team domain, e.g. https://<team>.cloudflareaccess.com |
ACCESS_AUD |
The Access application's AUD tag |
TWITTER_HANDLE |
Display-only handle posts go out from (CloudflareDev) |
| Secret | Purpose |
|---|---|
DEV_EMAIL |
Dev only - the signed-in identity when there's no Access JWT |
TWITTER_API_KEY |
OAuth 1.0a consumer key |
TWITTER_API_SECRET |
OAuth 1.0a consumer secret |
TWITTER_ACCESS_TOKEN |
The account's access token |
TWITTER_ACCESS_SECRET |
The account's access token secret |
Edit the allow-list in src/lib/admins.ts and redeploy. Emails are compared
case-insensitively.
# 1. install
pnpm install
# 2. create the Cloudflare resources (first time only)
npx wrangler d1 create socialite-db # paste database_id into wrangler.jsonc
npx wrangler r2 bucket create socialite-storage
# 3. configure local secrets
cp .dev.vars.example .dev.vars # set DEV_EMAIL + Twitter creds
# 4. run it (predev applies D1 migrations automatically)
pnpm devOpen http://localhost:3000. With no Access in front, you're signed in as
DEV_EMAIL - set it to an admin email to exercise the review/admin flows, or a
member email to see the author experience.
pnpm devruns the full Worker runtime via the Cloudflare Vite plugin, so bindings (D1, R2, Workers AI, Durable Objects) behave like production. The Docs MCP and Workers AI calls hit the real services.
| Script | What it does |
|---|---|
dev |
Vite dev server with the Cloudflare plugin + local D1 (runs db:migrate first via predev) |
build |
Production build |
preview |
Build + serve via vite preview (on workerd) |
deploy |
Build + wrangler deploy |
cf-typegen |
Regenerate worker-configuration.d.ts from wrangler.jsonc |
db:generate |
Generate SQL migrations from src/db/schema.ts |
db:migrate |
Apply migrations to local D1 |
db:migrate:prod |
Apply migrations to remote D1 |
db:studio |
Open Drizzle Studio |
test |
Run Vitest |
lint / format / typecheck |
ESLint / Prettier / tsc --noEmit |
# one-time: push secrets to the deployed Worker
wrangler secret put TWITTER_API_KEY
wrangler secret put TWITTER_API_SECRET
wrangler secret put TWITTER_ACCESS_TOKEN
wrangler secret put TWITTER_ACCESS_SECRET
# apply migrations to production D1
pnpm db:migrate:prod
# ship it
pnpm deployThen put the Worker behind a Cloudflare Access self-hosted application and
set ACCESS_TEAM_DOMAIN + ACCESS_AUD in wrangler.jsonc to match. Do not
set DEV_EMAIL in production.