Skip to content

Repository files navigation

Y

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.


Table of contents


Highlights

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

Tech stack

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)

Roles & auth

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 /admin Copilot 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_EMAIL from .dev.vars (set it to an admin or member email to test either experience). DEV_EMAIL must never be set in production - prod is fail-closed.


The post lifecycle

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 pending with a scheduledAt) → 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

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 - the writing assistant

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.

How a Copilot request flows

  ┌────────────┐   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):

  1. Auth + validation. /api/copilot/chat authenticates the Access user and validates the request (model + message history, Zod). It then forwards to the DraftAgent Durable Object via getAgentByName(env.DraftAgent, "cloudflaredev"). The agent is a single named instance shared by the whole team.

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

  3. Docs MCP connection. The agent ensures it's connected to the Cloudflare Docs MCP server and exposes those docs tools to the model.

  4. 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).
  5. 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 a title, angle, body, optional link, review notes, and a character count. These stream down as a final tweet-options event 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 recovers Option N blocks 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.


The "talk like me" skill: style guide + evals

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.

Style guide (src/lib/style-guide.ts, styleGuides table)

  • Corpus = real posted tweets (posts where status = '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 the posts table.
  • 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).

Reference tweets (src/lib/reference-tweets.ts, reference_tweets table)

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.

Evals (src/lib/style-eval.ts, eval_runs + eval_cases tables)

How do you know the guide is working? The eval harness gives you a number:

  1. Sample recent real posted tweets.
  2. Strip each one back to a neutral brief - the raw facts a user would have typed before writing it (no style, no phrasing).
  3. Have Copilot re-draft from that brief using the active style guide.
  4. 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).


Cloudflare Fact Check

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, or No 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.


How posting works

The Scheduler (src/durable/scheduler.ts)

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 the tweetId) or failed (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.

The X client (src/lib/twitter.ts)

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.


Project structure

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

Configuration

Bindings (wrangler.jsonc)

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

Vars (wrangler.jsoncvars)

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)

Secrets (.dev.vars locally, wrangler secret put in prod)

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

Admins

Edit the allow-list in src/lib/admins.ts and redeploy. Emails are compared case-insensitively.


Local development

# 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 dev

Open 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 dev runs 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.


Scripts

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

Deployment

# 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 deploy

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

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages