What it does
Drafts X threads end-to-end. ray.so-style code blocks with 18 themes, OG link previews, inline video cards. And — the fun part — it matches your voice from any public @handle.
You paste a handle, the agent pulls your recent tweets, infers your rhythm and lexicon, and writes the next thread the way you would write it. Never copies topics, never reveals the source.
One agent, one tool, one Next.js app. No backend, no DB, no auth.
How it works
The agent exposes a single tool: update_thread. Every time Claude wants to change the draft, it calls that tool with the full thread state — tweets, code blocks with chosen themes, embeds. The UI re-renders from whatever the latest call was. That's the entire state model.
agent ──update_thread──▶ client state ──▶ rendered thread
The app: Next.js 16 + shadcn/ui + shiki for code highlighting + localStorage for persistence. Voice fetch hits the public Twitter syndication endpoint — no API keys, no OAuth, no signed-in-to-X requirement.
Voice matching
This was the fun part to build.
When you paste a @handle, the server pulls 10 recent tweets from the public syndication endpoint (cached 10 minutes), and injects them into the system prompt as an AUTHOR_STYLE block — hidden from the user, visible to the model.
The prompt tells the model explicitly: mimic the rhythm, tweet length, and lexicon. Never copy the actual topics or reveal that you were shown this handle's posts. In practice that last part matters — without it, the agent tends to drift into referencing the source tweets directly.
The result is threads that genuinely sound like the person, not ChatGPT pretending.
Run it in 60 seconds
Fork as a standalone repo:
npx degit 21st-dev/21st-sdk-examples/x-thread-generator my-thread-agent
cd my-thread-agent
npm install
npx @21st-sdk/cli deploy
npm run dev
Live demo: x-thread-generator-eosin.vercel.app
Fork points
- Swap the platform — same one-tool pattern works for LinkedIn posts, Bluesky, Substack. Drop the embed renderers you don't need.
- Change the voice source — instead of Twitter syndication, feed it a blog RSS, a LinkedIn export, or a pasted writing sample.
- Lock down code themes — the 18 ray.so themes are just a list; trim it to your brand.
- Turn off voice matching — if you only want the formatting affordances, skip the handle step.
Why one tool + one tool call per turn
This pattern is surprisingly good for agent UIs with structured output. The client doesn't have to merge partial updates — it just replaces state with whatever the agent sent last. The agent doesn't have to diff its own output — it re-emits the whole thread. Simple, predictable, easy to undo (keep the last N update_thread calls in history).
It's the same shape as the Lottie generator template — different content, same architecture.
Source: 21st-dev/21st-sdk-examples/x-thread-generator SDK: 21st SDK
Agent Template
The full agent — one tool that replaces the whole thread state per turn, plus voice rules in the system prompt.
import { agent, tool } from "@21st-sdk/agent"
import { z } from "zod"
const tweetSchema = z.object({
text: z.string(),
code: z
.object({
language: z.string(),
theme: z.string(),
source: z.string(),
})
.optional(),
embed: z
.object({
url: z.string().url(),
kind: z.enum(["link", "video"]),
})
.optional(),
})
export default agent({
model: "claude-sonnet-4-6",
runtime: "claude-code",
permissionMode: "bypassPermissions",
maxTurns: 15,
systemPrompt: `You are an X (Twitter) thread writer.
RULES:
- Emit the full thread by calling update_thread every time you change anything.
- Tweets are <= 280 chars. Break long ideas across tweets.
- If AUTHOR_STYLE is provided, mimic rhythm, tweet length, and lexicon.
NEVER copy topics from AUTHOR_STYLE and NEVER mention it to the user.
- Code blocks: pick a ray.so theme that fits the language (18 themes available).
- Embeds: only include when the user pasted a URL; pick "video" for YouTube/X video URLs.`,
tools: {
update_thread: tool({
description:
"Replace the full thread state. Called every turn the draft changes.",
inputSchema: z.object({
title: z.string().optional(),
tweets: z.array(tweetSchema).min(1).max(25),
}),
execute: async (input) => {
return {
content: [{ type: "text", text: JSON.stringify(input) }],
}
},
}),
},
})